Merge branch 'master' into kireiev/AXM-549/feat/upstream_PR_active_inactive_courses_API

This commit is contained in:
Kyrylo Kireiev
2024-10-17 18:00:05 +03:00
committed by GitHub
109 changed files with 2235 additions and 1587 deletions

20
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -10,7 +10,7 @@ jobs:
matrix:
python-version:
- "3.11"
os: ["ubuntu-latest"]
os: ["ubuntu-22.04"]
steps:
- uses: actions/checkout@v4

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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]

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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):

View File

@@ -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):

View File

@@ -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()

View File

@@ -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):

View File

@@ -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.

View File

@@ -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'

View File

@@ -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"
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(
'',

View File

@@ -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

View File

@@ -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 -->

View File

@@ -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,

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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.

View File

@@ -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()

View File

@@ -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,

View File

@@ -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):

View File

@@ -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

View File

@@ -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),

View File

@@ -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"]):

View File

@@ -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)

View File

@@ -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>')

View File

@@ -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 = []

View File

@@ -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',
)

View File

@@ -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',
)

View File

@@ -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"

View File

@@ -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}" />

View File

@@ -1 +0,0 @@
../../../cms/templates/content_libraries/xblock_iframe.html

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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 systemthey'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
# ======================

View File

@@ -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,

View File

@@ -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?

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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.",
})

View File

@@ -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.

View File

@@ -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

View File

@@ -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',
),
]

View File

@@ -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,
)

View File

@@ -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__)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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
})

View File

@@ -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(

View File

@@ -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,

View File

@@ -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()

View File

@@ -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

View File

@@ -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):

View File

@@ -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"

View File

@@ -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)

View File

@@ -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),
])),
]

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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"""

View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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',

View File

@@ -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)

View File

@@ -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