diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index 10ddf984b4b8..23e93a114450 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -262,6 +262,36 @@ class StaticFileNotices: error_files: list[str] = Factory(list) +def _insert_static_files_into_downstream_xblock(downstream_xblock: XBlock, staged_content_id: int, request) -> StaticFileNotices: + """ + Gets static files from staged content, and inserts them into the downstream XBlock. + """ + static_files = content_staging_api.get_staged_content_static_files(staged_content_id) + notices, substitutions = _import_files_into_course( + course_key=downstream_xblock.context_key, + staged_content_id=staged_content_id, + static_files=static_files, + usage_key=downstream_xblock.scope_ids.usage_id, + ) + + + # Rewrite the OLX's static asset references to point to the new + # locations for those assets. See _import_files_into_course for more + # info on why this is necessary. + store = modulestore() + if hasattr(downstream_xblock, "data") and substitutions: + data_with_substitutions = downstream_xblock.data + for old_static_ref, new_static_ref in substitutions.items(): + data_with_substitutions = data_with_substitutions.replace( + old_static_ref, + new_static_ref, + ) + downstream_xblock.data = data_with_substitutions + if store is not None: + store.update_item(downstream_xblock, request.user.id) + return notices + + def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tuple[XBlock | None, StaticFileNotices]: """ Import a block (along with its children and any required static assets) from @@ -299,71 +329,37 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tags=user_clipboard.content.tags, ) - # Now handle static files that need to go into Files & Uploads. - static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id) - notices, substitutions = _import_files_into_course( - course_key=parent_key.context_key, - staged_content_id=user_clipboard.content.id, - static_files=static_files, - usage_key=new_xblock.scope_ids.usage_id, - ) - - # Rewrite the OLX's static asset references to point to the new - # locations for those assets. See _import_files_into_course for more - # info on why this is necessary. - if hasattr(new_xblock, 'data') and substitutions: - data_with_substitutions = new_xblock.data - for old_static_ref, new_static_ref in substitutions.items(): - data_with_substitutions = data_with_substitutions.replace( - old_static_ref, - new_static_ref, - ) - new_xblock.data = data_with_substitutions - store.update_item(new_xblock, request.user.id) + notices = _insert_static_files_into_downstream_xblock(new_xblock, user_clipboard.content.id, request) return new_xblock, notices -def import_staged_content_for_library_sync(new_xblock: XBlock, lib_block: XBlock, request) -> StaticFileNotices: +def import_staged_content_for_library_sync(downstream_xblock: XBlock, lib_block: XBlock, request) -> StaticFileNotices: """ - Import a block (along with its children and any required static assets) from - the "staged" OLX in the user's clipboard. + Import the static assets from the library xblock to the downstream xblock + through staged content. Also updates the OLX references to point to the new + locations of those assets in the downstream course. Does not deal with permissions or REST stuff - do that before calling this. - Returns (1) the newly created block on success or None if the clipboard is - empty, and (2) a summary of changes made to static files in the destination + Returns a summary of changes made to static files in the destination course. """ if not content_staging_api: raise RuntimeError("The required content_staging app is not installed") - library_sync = content_staging_api.save_xblock_to_user_library_sync(lib_block, request.user.id) - if not library_sync: + staged_content = content_staging_api.stage_xblock_temporarily(lib_block, request.user.id) + if not staged_content: # expired/error/loading return StaticFileNotices() store = modulestore() - with store.bulk_operations(new_xblock.scope_ids.usage_id.context_key): + with store.bulk_operations(downstream_xblock.context_key): # Now handle static files that need to go into Files & Uploads. - static_files = content_staging_api.get_staged_content_static_files(library_sync.content.id) - notices, substitutions = _import_files_into_course( - course_key=new_xblock.scope_ids.usage_id.context_key, - staged_content_id=library_sync.content.id, - static_files=static_files, - usage_key=new_xblock.scope_ids.usage_id, - ) - - # Rewrite the OLX's static asset references to point to the new - # locations for those assets. See _import_files_into_course for more - # info on why this is necessary. - if hasattr(new_xblock, "data") and substitutions: - data_with_substitutions = new_xblock.data - for old_static_ref, new_static_ref in substitutions.items(): - data_with_substitutions = data_with_substitutions.replace( - old_static_ref, - new_static_ref, - ) - new_xblock.data = data_with_substitutions + # If the required files already exist, nothing will happen. + try: + notices = _insert_static_files_into_downstream_xblock(downstream_xblock, staged_content.id, request) + finally: + staged_content.delete() return notices @@ -592,6 +588,9 @@ def _import_files_into_course( if result is True: new_files.append(file_data_obj.filename) substitutions.update(substitution_for_file) + elif substitution_for_file: + # substitutions need to be made because OLX references to these files need to be updated + substitutions.update(substitution_for_file) elif result is None: pass # This file already exists; no action needed. else: @@ -662,8 +661,8 @@ def _import_file_into_course( contentstore().save(content) return True, {clipboard_file_path: f"static/{import_path}"} elif current_file.content_digest == file_data_obj.md5_hash: - # The file already exists and matches exactly, so no action is needed - return None, {} + # The file already exists and matches exactly, so no action is needed except substitutions + return None, {clipboard_file_path: f"static/{import_path}"} else: # There is a conflict with some other file that has the same name. return False, {} diff --git a/openedx/core/djangoapps/content_staging/api.py b/openedx/core/djangoapps/content_staging/api.py index 8ebdc1c9f243..4e5e11eddb3e 100644 --- a/openedx/core/djangoapps/content_staging/api.py +++ b/openedx/core/djangoapps/content_staging/api.py @@ -25,17 +25,14 @@ StagedContentFileData, StagedContentStatus, UserClipboardData, - UserLibrarySyncData, ) from .models import ( UserClipboard as _UserClipboard, StagedContent as _StagedContent, StagedContentFile as _StagedContentFile, - UserLibrarySync as _UserLibrarySync, ) from .serializers import ( UserClipboardSerializer as _UserClipboardSerializer, - UserLibrarySyncSerializer as _UserLibrarySyncSerializer, ) from .tasks import delete_expired_clipboards @@ -168,25 +165,16 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int return _user_clipboard_model_to_data(clipboard) -def save_xblock_to_user_library_sync( - block: XBlock, user_id: int, version_num: int | None = None -) -> UserLibrarySyncData: +def stage_xblock_temporarily( + block: XBlock, user_id: int, purpose: str = LIBRARY_SYNC_PURPOSE, version_num: int | None = None, +) -> _StagedContent: """ - Save an XBlock's OLX for library sync. + "Stage" an XBlock by copying it (and its associated children + static assets) + into the content staging area. This XBlock can then be accessed and manipulated + using any of the staged content APIs, before being deleted. """ - staged_content = _save_xblock_to_staged_content(block, user_id, LIBRARY_SYNC_PURPOSE, version_num) - usage_key = block.usage_key - - # Create/update the library sync entry - (sync, _created) = _UserLibrarySync.objects.update_or_create( - user_id=user_id, - defaults={ - "content": staged_content, - "source_usage_key": usage_key, - }, - ) - - return _user_library_sync_model_to_data(sync) + staged_content = _save_xblock_to_staged_content(block, user_id, purpose, version_num) + return staged_content def get_user_clipboard(user_id: int, only_ready: bool = True) -> UserClipboardData | None: @@ -235,31 +223,6 @@ def get_user_clipboard_json(user_id: int, request: HttpRequest | None = None): return serializer.data -def get_user_library_sync_json(user_id: int, request: HttpRequest | None = None): - """ - Get the detailed status of the user's library sync, in exactly the same format - as returned from the - /api/content-staging/v1/library-sync/ - REST API endpoint. This version of the API is meant for "preloading" that - REST API endpoint so it can be embedded in a larger response sent to the - user's browser. - - (request is optional; including it will make the "olx_url" absolute instead - of relative.) - """ - try: - sync = _UserLibrarySync.objects.get(user_id=user_id) - except _UserLibrarySync.DoesNotExist: - # This user does not have any library sync content. - return {"content": None, "source_usage_key": "", "source_context_title": "", "source_edit_url": ""} - - serializer = _UserLibrarySyncSerializer( - _user_library_sync_model_to_data(sync), - context={"request": request}, - ) - return serializer.data - - def _staged_content_to_data(content: _StagedContent) -> StagedContentData: """ Convert a StagedContent model instance to an immutable data object. @@ -288,17 +251,6 @@ def _user_clipboard_model_to_data(clipboard: _UserClipboard) -> UserClipboardDat ) -def _user_library_sync_model_to_data(sync: _UserLibrarySync) -> UserLibrarySyncData: - """ - Convert a UserLibrarySync model instance to an immutable data object. - """ - return UserLibrarySyncData( - content=_staged_content_to_data(sync.content), - source_usage_key=sync.source_usage_key, - source_context_title=sync.get_source_context_title(), - ) - - def get_staged_content_olx(staged_content_id: int) -> str | None: """ Get the OLX (as a string) for the given StagedContent. diff --git a/openedx/core/djangoapps/content_staging/data.py b/openedx/core/djangoapps/content_staging/data.py index 4d535c9fcfcf..d095f2506b17 100644 --- a/openedx/core/djangoapps/content_staging/data.py +++ b/openedx/core/djangoapps/content_staging/data.py @@ -76,17 +76,3 @@ class UserClipboardData: def source_context_key(self) -> LearningContextKey: """ Get the context (course/library) that this was copied from """ return self.source_usage_key.context_key - - -@frozen -class UserLibrarySyncData: - """Read-only data model for User Library Sync data""" - - content: StagedContentData = field(validator=validators.instance_of(StagedContentData)) - source_usage_key: UsageKey = field(validator=validators.instance_of(UsageKey)) # type: ignore[type-abstract] - source_context_title: str - - @property - def source_context_key(self) -> LearningContextKey: - """Get the context (course/library) that this was copied from""" - return self.source_usage_key.context_key diff --git a/openedx/core/djangoapps/content_staging/migrations/0006_userlibrarysync.py b/openedx/core/djangoapps/content_staging/migrations/0006_userlibrarysync.py deleted file mode 100644 index 99ee67b18892..000000000000 --- a/openedx/core/djangoapps/content_staging/migrations/0006_userlibrarysync.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 4.2.17 on 2025-01-25 01:10 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import opaque_keys.edx.django.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - ("content_staging", "0005_stagedcontent_version_num"), - ] - - operations = [ - migrations.CreateModel( - name="UserLibrarySync", - fields=[ - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - primary_key=True, - serialize=False, - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "source_usage_key", - opaque_keys.edx.django.models.UsageKeyField( - help_text="Original usage key/ID of the thing that is being synced.", max_length=255 - ), - ), - ( - "content", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="content_staging.stagedcontent"), - ), - ], - ), - ] diff --git a/openedx/core/djangoapps/content_staging/models.py b/openedx/core/djangoapps/content_staging/models.py index 65e8c8326a44..307957cc213e 100644 --- a/openedx/core/djangoapps/content_staging/models.py +++ b/openedx/core/djangoapps/content_staging/models.py @@ -144,43 +144,3 @@ def save(self, *args, **kwargs): # Enforce checks on save: self.full_clean() return super().save(*args, **kwargs) - - -class UserLibrarySync(models.Model): - """ - Each user can trigger a sync from a library component to that component in a course. - This model is used to facilitate that and to ease tracking. - """ - - user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) - content = models.ForeignKey(StagedContent, on_delete=models.CASCADE) - source_usage_key = UsageKeyField( - max_length=255, - help_text=_("Original usage key/ID of the thing that is being synced."), - ) - - @property - def source_context_key(self) -> LearningContextKey: - """Get the context (library) that this was copied from""" - return self.source_usage_key.context_key - - def get_source_context_title(self) -> str: - """Get the title of the source context, if any""" - # Just return the ID as the name, since it can only be a library - return str(self.source_context_key) - - def clean(self): - """Check that this model is being used correctly.""" - # These could probably be replaced with constraints in Django 4.1+ - if self.user.id != self.content.user.id: - raise ValidationError("User ID mismatch.") - if self.content.purpose != LIBRARY_SYNC_PURPOSE: - raise ValidationError( - f"StagedContent.purpose must be '{CLIPBOARD_PURPOSE}' to use it as clipboard content." - ) - - def save(self, *args, **kwargs): - """Save this model instance""" - # Enforce checks on save: - self.full_clean() - return super().save(*args, **kwargs) diff --git a/openedx/core/djangoapps/content_staging/serializers.py b/openedx/core/djangoapps/content_staging/serializers.py index ca1847ca854c..fff9ff316e6f 100644 --- a/openedx/core/djangoapps/content_staging/serializers.py +++ b/openedx/core/djangoapps/content_staging/serializers.py @@ -77,14 +77,3 @@ class PostToClipboardSerializer(serializers.Serializer): user's clipboard. """ usage_key = serializers.CharField(help_text="Usage key to copy into the clipboard") - - -class UserLibrarySyncSerializer(serializers.Serializer): - """ - Serializer for the status of the user's library sync (a UserLibrarySyncData instance) - """ - - content = StagedContentSerializer(allow_null=True) - source_usage_key = serializers.CharField(allow_blank=True) - # The title of the course that the content came from originally, if relevant - source_context_title = serializers.CharField(allow_blank=True)