Skip to content

Commit

Permalink
feat: remove user library sync
Browse files Browse the repository at this point in the history
  • Loading branch information
DanielVZ96 committed Jan 29, 2025
1 parent f12ff12 commit ecdc8dc
Show file tree
Hide file tree
Showing 6 changed files with 57 additions and 212 deletions.
99 changes: 49 additions & 50 deletions cms/djangoapps/contentstore/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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, {}
Expand Down
64 changes: 8 additions & 56 deletions openedx/core/djangoapps/content_staging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 0 additions & 14 deletions openedx/core/djangoapps/content_staging/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

This file was deleted.

40 changes: 0 additions & 40 deletions openedx/core/djangoapps/content_staging/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
11 changes: 0 additions & 11 deletions openedx/core/djangoapps/content_staging/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit ecdc8dc

Please sign in to comment.