diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index f117d2762949..131cf3536a30 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -3,6 +3,7 @@ """ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from django.utils.translation import gettext as _ from opaque_keys.edx.locator import LibraryUsageLocatorV2 @@ -15,6 +16,7 @@ __all__ = [ "LibraryXBlockMetadata", "LibraryXBlockStaticFile", + "LibraryComponentDraftHistoryEntry", ] @@ -64,6 +66,17 @@ def from_component(cls, library_key, component, associated_collections=None): ) +@dataclass(frozen=True) +class LibraryComponentDraftHistoryEntry: + """ + One entry in the draft change history of a library component. + """ + changed_by: object # AUTH_USER_MODEL instance or None + changed_at: datetime + title: str # title at time of change + action: str # "edited" | "renamed" + + @dataclass(frozen=True) class LibraryXBlockStaticFile: """ diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index dc0913d0fdc7..53f377b588f5 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -58,7 +58,7 @@ InvalidNameError, LibraryBlockAlreadyExists, ) -from .block_metadata import LibraryXBlockMetadata, LibraryXBlockStaticFile +from .block_metadata import LibraryComponentDraftHistoryEntry, LibraryXBlockMetadata, LibraryXBlockStaticFile from .containers import ( create_container, get_container, @@ -98,6 +98,7 @@ "add_library_block_static_asset_file", "delete_library_block_static_asset_file", "publish_component_changes", + "get_library_component_draft_history", ] @@ -191,6 +192,37 @@ def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=Fals return xblock_metadata +def get_library_component_draft_history(usage_key: LibraryUsageLocatorV2) -> list[LibraryComponentDraftHistoryEntry]: + """ + Return the draft change history for a library component since its last publication, + ordered from most recent to oldest. + + Raises ContentLibraryBlockNotFound if the component does not exist. + """ + try: + component = get_component_from_usage_key(usage_key) + except ObjectDoesNotExist as exc: + raise ContentLibraryBlockNotFound(usage_key) from exc + + records = content_api.get_entity_draft_history(component.publishable_entity) + + return [ + LibraryComponentDraftHistoryEntry( + changed_by=record.draft_change_log.changed_by, + changed_at=record.draft_change_log.changed_at, + title=(record.new_version or record.old_version).title, + action=_resolve_draft_action(record.old_version, record.new_version), + ) + for record in records + ] + + +def _resolve_draft_action(old_version, new_version) -> str: + if old_version and new_version and old_version.title != new_version.title: + return "renamed" + return "edited" + + def set_library_block_olx(usage_key: LibraryUsageLocatorV2, new_olx_str: str) -> ComponentVersion: """ Replace the OLX source of the given XBlock. @@ -682,7 +714,6 @@ def import_staged_content_from_user_clipboard(library_key: LibraryLocatorV2, use now, ) - def get_or_create_olx_media_type(block_type: str) -> MediaType: """ Get or create a MediaType for the block type. diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index 86bd8f6112dd..d8340b5c48b3 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -140,6 +140,25 @@ def delete(self, request, usage_key_str): # pylint: disable=unused-argument return Response({}) +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryComponentDraftHistoryView(APIView): + """ + View to get the draft change history of a library component. + """ + serializer_class = serializers.LibraryComponentDraftHistoryEntrySerializer + + @convert_exceptions + def get(self, request, usage_key_str): + """ + Get the draft change history for a library component since its last publication. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + history = api.get_library_component_draft_history(key) + return Response(self.serializer_class(history, many=True).data) + + @method_decorator(non_atomic_requests, name="dispatch") @view_auth_classes() class LibraryBlockAssetListView(APIView): diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index 87a5dd3e3b6f..a4a73cecd158 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -180,6 +180,21 @@ class LibraryXBlockMetadataSerializer(PublishableItemSerializer): block_type = serializers.CharField(source="usage_key.block_type") +class LibraryComponentDraftHistoryEntrySerializer(serializers.Serializer): + """ + Serializer for a single entry in the draft history of a library component. + """ + changed_by = serializers.SerializerMethodField() + changed_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + title = serializers.CharField() + action = serializers.CharField() + + def get_changed_by(self, obj) -> str | None: + if obj.changed_by is None: + return None + return obj.changed_by.username + + class LibraryXBlockTypeSerializer(serializers.Serializer): """ Serializer for LibraryXBlockType diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 9dc12e943156..ff1c12b1a4f3 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -77,6 +77,8 @@ path('assets/', blocks.LibraryBlockAssetListView.as_view()), path('assets/', blocks.LibraryBlockAssetView.as_view()), path('publish/', blocks.LibraryBlockPublishView.as_view()), + # Get the draft change history for this block + path('draft_history/', blocks.LibraryComponentDraftHistoryView.as_view()), # Future: discard changes for just this one block ])), # Containers are Sections, Subsections, and Units