diff --git a/cms/envs/test.py b/cms/envs/test.py index 38b7c7817149..49db50608858 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -333,3 +333,13 @@ "SECRET": "***", "URL": "***", } + +############## openedx-learning (Learning Core) config ############## +OPENEDX_LEARNING = { + 'MEDIA': { + 'BACKEND': 'django.core.files.storage.InMemoryStorage', + 'OPTIONS': { + 'location': MEDIA_ROOT + "_private" + } + } +} diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index b9f3779af539..5562a8c97806 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -56,6 +56,7 @@ import base64 import hashlib import logging +import mimetypes import attr import requests @@ -68,6 +69,7 @@ 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 ( @@ -95,7 +97,11 @@ 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 @@ -1001,18 +1007,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 +1060,58 @@ 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... + 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) -def delete_library_block_static_asset_file(usage_key, file_name): + media_type_str, _encoding = mimetypes.guess_type(file_path) + media_type = authoring_api.get_or_create_media_type(media_type_str) + now = datetime.now(tz=timezone.utc) + + with transaction.atomic(): + 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_path, user=None): """ Delete a static asset file from the library. @@ -1037,7 +1121,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 diff --git a/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py b/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py index 92ff4c1767d0..fe57f785836c 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py @@ -23,15 +23,9 @@ """ -@skip("Assets are being reimplemented in Learning Core. Disable until that's ready.") 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): diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index c571cce86be1..e1a49146d096 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -71,6 +71,7 @@ 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, HttpResponse, HttpResponseBadRequest, JsonResponse, StreamingHttpResponse from django.shortcuts import get_object_or_404 @@ -87,6 +88,7 @@ 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 @@ -757,7 +759,7 @@ def put(self, request, usage_key_str, file_path): 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) @@ -772,7 +774,7 @@ def delete(self, request, usage_key_str, file_path): 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) @@ -1125,7 +1127,7 @@ def component_version_asset(request, component_version_uuid, asset_path): eventually). """ try: - component_version = authoring_api.get_component_version_by_uuid( + component_version = authoring.get_component_version_by_uuid( component_version_uuid ) except ObjectDoesNotExist: @@ -1144,7 +1146,7 @@ def component_version_asset(request, component_version_uuid, asset_path): # 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_api.get_redirect_response_for_component_asset( + redirect_response = authoring.get_redirect_response_for_component_asset( component_version_uuid, asset_path, public=False,