From 9e5a1c5fee94cba19d3c4e481ea48a7571f0812a Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 16 Mar 2026 18:18:12 -0500 Subject: [PATCH 1/4] feat: Python api and rest api added for component draft history --- .../content_libraries/api/block_metadata.py | 13 +++++++ .../content_libraries/api/blocks.py | 35 +++++++++++++++++-- .../content_libraries/rest_api/blocks.py | 19 ++++++++++ .../content_libraries/rest_api/serializers.py | 15 ++++++++ .../core/djangoapps/content_libraries/urls.py | 2 ++ 5 files changed, 82 insertions(+), 2 deletions(-) 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 From 80c9155d18aaa0d0c792fc535726da7c2be76386 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 20 Mar 2026 19:35:37 -0500 Subject: [PATCH 2/4] feat: publish history and entris functions added --- .../content_libraries/api/block_metadata.py | 18 ++ .../content_libraries/api/blocks.py | 108 +++++++- .../content_libraries/api/libraries.py | 1 + .../content_libraries/rest_api/blocks.py | 41 +++ .../content_libraries/rest_api/serializers.py | 18 ++ .../content_libraries/tests/base.py | 19 ++ .../tests/test_content_libraries.py | 244 ++++++++++++++++++ .../core/djangoapps/content_libraries/urls.py | 7 + 8 files changed, 447 insertions(+), 9 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 131cf3536a30..0ae31baa9cb0 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -17,6 +17,7 @@ "LibraryXBlockMetadata", "LibraryXBlockStaticFile", "LibraryComponentDraftHistoryEntry", + "LibraryComponentPublishHistoryGroup", ] @@ -51,6 +52,7 @@ def from_component(cls, library_key, component, associated_collections=None): usage_key=usage_key, display_name=draft.title, created=component.created, + created_by=component.created_by.username if component.created_by else None, modified=draft.created, draft_version_num=draft.version_num, published_version_num=published.version_num if published else None, @@ -77,6 +79,22 @@ class LibraryComponentDraftHistoryEntry: action: str # "edited" | "renamed" +@dataclass(frozen=True) +class LibraryComponentPublishHistoryGroup: + """ + Summary of a publish event for a library component. + + Each instance represents one PublishLogRecord for the component, and + includes the set of contributors who authored draft changes between the + previous publish and this one. + """ + publish_log_uuid: str + published_by: object # AUTH_USER_MODEL instance or None + published_at: datetime + contributors: list # list of AUTH_USER_MODEL, distinct authors of versions in this group + contributors_count: int + + @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 53f377b588f5..d39d891fa6a1 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -58,7 +58,12 @@ InvalidNameError, LibraryBlockAlreadyExists, ) -from .block_metadata import LibraryComponentDraftHistoryEntry, LibraryXBlockMetadata, LibraryXBlockStaticFile +from .block_metadata import ( + LibraryComponentDraftHistoryEntry, + LibraryComponentPublishHistoryGroup, + LibraryXBlockMetadata, + LibraryXBlockStaticFile, +) from .containers import ( create_container, get_container, @@ -99,6 +104,8 @@ "delete_library_block_static_asset_file", "publish_component_changes", "get_library_component_draft_history", + "get_library_component_publish_history", + "get_library_component_publish_history_entries", ] @@ -206,23 +213,106 @@ def get_library_component_draft_history(usage_key: LibraryUsageLocatorV2) -> lis records = content_api.get_entity_draft_history(component.publishable_entity) - return [ - LibraryComponentDraftHistoryEntry( + entries = [] + for record in records: + version = record.new_version if record.new_version is not None else record.old_version + entries.append(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 - ] + title=version.title if version is not None else "", + action=_resolve_component_change_action(record.old_version, record.new_version), + )) + return entries -def _resolve_draft_action(old_version, new_version) -> str: +def _resolve_component_change_action(old_version, new_version) -> str: if old_version and new_version and old_version.title != new_version.title: return "renamed" return "edited" +def get_library_component_publish_history( + usage_key: LibraryUsageLocatorV2, +) -> list[LibraryComponentPublishHistoryGroup]: + """ + Return the publish history of a library component as a list of groups. + + Each group corresponds to one publish event (PublishLogRecord) and includes: + - who published and when + - the distinct set of contributors: users who authored draft changes between + the previous publish and this one (via DraftChangeLogRecord version bounds) + + Groups are ordered most-recent-first. Returns [] if the component has never + been published. + + Contributors are resolved using version bounds (old_version_num → new_version_num) + rather than timestamps to avoid clock-skew issues. old_version_num defaults to + 0 for the very first publish. new_version_num is None for soft-delete publishes + (no PublishableEntityVersion is created on soft delete). + """ + try: + component = get_component_from_usage_key(usage_key) + except ObjectDoesNotExist as exc: + raise ContentLibraryBlockNotFound(usage_key) from exc + + entity = component.publishable_entity + publish_records = list(content_api.get_entity_publish_history(entity)) + + groups = [] + for pub_record in publish_records: + # old_version is None only for the very first publish (entity had no prior published version) + old_version_num = pub_record.old_version.version_num if pub_record.old_version else 0 + # new_version is None for soft-delete publishes (component deleted without a new draft version) + new_version_num = pub_record.new_version.version_num if pub_record.new_version else None + + contributors = list(content_api.get_entity_version_contributors( + entity, + old_version_num=old_version_num, + new_version_num=new_version_num, + )) + + groups.append(LibraryComponentPublishHistoryGroup( + publish_log_uuid=str(pub_record.publish_log.uuid), + published_by=pub_record.publish_log.published_by, + published_at=pub_record.publish_log.published_at, + contributors=contributors, + contributors_count=len(contributors), + )) + + return groups + + +def get_library_component_publish_history_entries( + usage_key: LibraryUsageLocatorV2, + publish_log_uuid: str, +) -> list[LibraryComponentDraftHistoryEntry]: + """ + Return the individual draft change entries for a specific publish event. + + Called lazily when the user expands a publish event in the UI. Entries are + the DraftChangeLogRecords that fall between the previous publish event and + this one, ordered most-recent-first. + """ + try: + component = get_component_from_usage_key(usage_key) + except ObjectDoesNotExist as exc: + raise ContentLibraryBlockNotFound(usage_key) from exc + + records = content_api.get_entity_publish_history_entries( + component.publishable_entity, publish_log_uuid + ) + entries = [] + for r in records: + version = r.new_version if r.new_version is not None else r.old_version + entries.append(LibraryComponentDraftHistoryEntry( + changed_by=r.draft_change_log.changed_by, + changed_at=r.draft_change_log.changed_at, + title=version.title if version is not None else "", + action=_resolve_component_change_action(r.old_version, r.new_version), + )) + return entries + + def set_library_block_olx(usage_key: LibraryUsageLocatorV2, new_olx_str: str) -> ComponentVersion: """ Replace the OLX source of the given XBlock. diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index fce2ce5ec4f6..3257f602ee3b 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -213,6 +213,7 @@ class PublishableItem(LibraryItem): has_unpublished_changes: bool = False collections: list[CollectionMetadata] = dataclass_field(default_factory=list) can_stand_alone: bool = True + created_by: str | None = None @dataclass(frozen=True) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index d8340b5c48b3..62bc4e183aaf 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -159,6 +159,47 @@ def get(self, request, usage_key_str): return Response(self.serializer_class(history, many=True).data) +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryComponentPublishHistoryView(APIView): + """ + View to get the publish history of a library component as a list of publish events. + """ + serializer_class = serializers.LibraryComponentPublishHistoryGroupSerializer + + @convert_exceptions + def get(self, request, usage_key_str): + """ + Get the publish history for a library component, ordered most-recent-first. + """ + 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_publish_history(key) + return Response(self.serializer_class(history, many=True).data) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryComponentPublishHistoryEntriesView(APIView): + """ + View to get the individual draft change entries for a specific publish event. + """ + serializer_class = serializers.LibraryComponentDraftHistoryEntrySerializer + + @convert_exceptions + def get(self, request, usage_key_str, publish_log_uuid): + """ + Get the draft change entries for a specific publish event, ordered most-recent-first. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + try: + entries = api.get_library_component_publish_history_entries(key, publish_log_uuid) + except ObjectDoesNotExist as exc: + raise NotFound(f"No publish event '{publish_log_uuid}' found for this component.") from exc + return Response(self.serializer_class(entries, 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 a4a73cecd158..31e24109671d 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -151,6 +151,7 @@ class PublishableItemSerializer(serializers.Serializer): last_draft_created_by = serializers.CharField(read_only=True) has_unpublished_changes = serializers.BooleanField(read_only=True) created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + created_by = serializers.CharField(read_only=True, allow_null=True) modified = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) # When creating a new XBlock in a library, the slug becomes the ID part of @@ -195,6 +196,23 @@ def get_changed_by(self, obj) -> str | None: return obj.changed_by.username +class LibraryComponentPublishHistoryGroupSerializer(serializers.Serializer): + """ + Serializer for a publish event summary in the publish history of a library component. + """ + publish_log_uuid = serializers.CharField(read_only=True) + published_by = serializers.SerializerMethodField() + published_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + contributors = serializers.SerializerMethodField() + contributors_count = serializers.IntegerField(read_only=True) + + def get_published_by(self, obj) -> str | None: + return obj.published_by.username if obj.published_by else None + + def get_contributors(self, obj) -> list[str]: + return [u.username for u in obj.contributors] + + class LibraryXBlockTypeSerializer(serializers.Serializer): """ Serializer for LibraryXBlockType diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 04a5386c3127..f857a92ce5c6 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -38,6 +38,9 @@ URL_LIB_RESTORE_GET = URL_LIB_RESTORE + '?{query_params}' # Get status/result of a library restore task URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/' # Get data about a block, or delete it URL_LIB_BLOCK_PUBLISH = URL_LIB_BLOCK + 'publish/' # Publish changes from a specified XBlock +URL_LIB_BLOCK_DRAFT_HISTORY = URL_LIB_BLOCK + 'draft_history/' # Draft change history for a block +URL_LIB_BLOCK_PUBLISH_HISTORY = URL_LIB_BLOCK + 'publish_history/' # Publish event history for a block +URL_LIB_BLOCK_PUBLISH_HISTORY_ENTRIES = URL_LIB_BLOCK_PUBLISH_HISTORY + '{publish_log_uuid}/entries/' URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specified XBlock URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file @@ -321,6 +324,22 @@ def _publish_library_block(self, block_key, expect_response=200): """ Publish changes from a specified XBlock """ return self._api('post', URL_LIB_BLOCK_PUBLISH.format(block_key=block_key), None, expect_response) + def _get_block_draft_history(self, block_key, expect_response=200): + """ Get the draft change history for a block since its last publication """ + return self._api('get', URL_LIB_BLOCK_DRAFT_HISTORY.format(block_key=block_key), None, expect_response) + + def _get_block_publish_history(self, block_key, expect_response=200): + """ Get the publish event history for a block """ + return self._api('get', URL_LIB_BLOCK_PUBLISH_HISTORY.format(block_key=block_key), None, expect_response) + + def _get_block_publish_history_entries(self, block_key, publish_log_uuid, expect_response=200): + """ Get the draft change entries for a specific publish event """ + url = URL_LIB_BLOCK_PUBLISH_HISTORY_ENTRIES.format( + block_key=block_key, + publish_log_uuid=publish_log_uuid, + ) + return self._api('get', url, None, expect_response) + def _paste_clipboard_content_in_library(self, lib_key, expect_response=200): """ Paste's the users clipboard content into Library """ url = URL_LIB_PASTE_CLIPBOARD.format(lib_key=lib_key) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index aba221dac821..cd791282ca46 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -890,6 +890,250 @@ def test_library_get_enabled_blocks(self): block_types = self._get_library_block_types(lib_id) assert [dict(item) for item in block_types] == expected + def test_draft_history_empty_after_publish(self): + """ + A block with no unpublished changes since its last publish has an empty draft history. + """ + lib = self._create_library(slug="draft-hist-empty", title="Draft History Empty") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + self._publish_library_block(block_key) + + history = self._get_block_draft_history(block_key) + assert history == [] + + def test_draft_history_shows_unpublished_edits(self): + """ + Draft history contains entries for edits made since the last publication, + ordered most-recent-first, with the correct fields. + """ + lib = self._create_library(slug="draft-hist-edits", title="Draft History Edits") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + self._publish_library_block(block_key) + + edit1_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc) + with freeze_time(edit1_time): + self._set_library_block_olx(block_key, "

edit 1

") + + edit2_time = datetime(2026, 4, 2, 10, 0, 0, tzinfo=timezone.utc) + with freeze_time(edit2_time): + self._set_library_block_olx(block_key, "

edit 2

") + + history = self._get_block_draft_history(block_key) + assert len(history) == 2 + assert history[0]["changed_at"] == edit2_time.isoformat().replace("+00:00", "Z") + assert history[1]["changed_at"] == edit1_time.isoformat().replace("+00:00", "Z") + entry = history[0] + assert "changed_by" in entry + assert "title" in entry + assert "action" in entry + + def test_draft_history_action_renamed(self): + """ + When the title changes between versions, the action is 'renamed'. + """ + lib = self._create_library(slug="draft-hist-rename", title="Draft History Rename") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + self._publish_library_block(block_key) + self._set_library_block_olx( + block_key, + '

content

', + ) + + history = self._get_block_draft_history(block_key) + assert len(history) >= 1 + assert history[0]["action"] == "renamed" + + def test_draft_history_action_edited(self): + """ + When only the content changes (not the title), the action is 'edited'. + """ + lib = self._create_library(slug="draft-hist-edit", title="Draft History Edit") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + self._publish_library_block(block_key) + self._set_library_block_olx(block_key, "

changed content

") + + history = self._get_block_draft_history(block_key) + assert len(history) >= 1 + assert history[0]["action"] == "edited" + + def test_draft_history_cleared_after_publish(self): + """ + After publishing, the draft history resets to empty. + """ + lib = self._create_library(slug="draft-hist-clear", title="Draft History Clear") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + self._publish_library_block(block_key) + self._set_library_block_olx(block_key, "

unpublished

") + assert len(self._get_block_draft_history(block_key)) >= 1 + + self._publish_library_block(block_key) + assert self._get_block_draft_history(block_key) == [] + + def test_draft_history_nonexistent_block(self): + """ + Requesting draft history for a non-existent block returns 404. + """ + self._get_block_draft_history("lb:CL-TEST:draft-hist-404:problem:nope", expect_response=404) + + def test_draft_history_permissions(self): + """ + A user without library access receives 403. + """ + lib = self._create_library(slug="draft-hist-auth", title="Draft History Auth") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._set_library_block_olx(block_key, "

edit

") + + unauthorized = UserFactory.create(username="noauth-draft", password="edx") + with self.as_user(unauthorized): + self._get_block_draft_history(block_key, expect_response=403) + + def test_publish_history_empty_before_first_publish(self): + """ + A block that has never been published has an empty publish history. + """ + lib = self._create_library(slug="hist-empty", title="History Empty") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + history = self._get_block_publish_history(block["id"]) + assert history == [] + + def test_publish_history_after_single_publish(self): + """ + After one publish the history contains exactly one group with the + correct publisher, timestamp, and contributor. + """ + lib = self._create_library(slug="hist-single", title="History Single") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + publish_time = datetime(2026, 1, 10, 12, 0, 0, tzinfo=timezone.utc) + with freeze_time(publish_time): + self._publish_library_block(block_key) + + history = self._get_block_publish_history(block_key) + assert len(history) == 1 + group = history[0] + assert group["published_by"] == self.user.username + assert group["published_at"] == publish_time.isoformat().replace("+00:00", "Z") + assert isinstance(group["publish_log_uuid"], str) + assert group["contributors_count"] >= 1 + assert self.user.username in group["contributors"] + + def test_publish_history_multiple_publishes(self): + """ + Multiple publish events are returned newest-first. + """ + lib = self._create_library(slug="hist-multi", title="History Multi") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + first_publish = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + with freeze_time(first_publish): + self._publish_library_block(block_key) + + self._set_library_block_olx(block_key, "

v2

") + + second_publish = datetime(2026, 2, 1, 0, 0, 0, tzinfo=timezone.utc) + with freeze_time(second_publish): + self._publish_library_block(block_key) + + history = self._get_block_publish_history(block_key) + assert len(history) == 2 + assert history[0]["published_at"] == second_publish.isoformat().replace("+00:00", "Z") + assert history[1]["published_at"] == first_publish.isoformat().replace("+00:00", "Z") + + def test_publish_history_tracks_contributors(self): + """ + Contributors for the first publish include the block creator. + Note: set_library_block_olx does not record created_by, so OLX + edits are not tracked as contributions. + """ + lib = self._create_library(slug="hist-contrib", title="History Contributors") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + self._publish_library_block(block_key) + + history = self._get_block_publish_history(block_key) + assert len(history) == 1 + group = history[0] + assert group["contributors_count"] >= 1 + assert self.user.username in group["contributors"] + + def test_publish_history_entries(self): + """ + The entries endpoint returns the individual draft change records for a publish event. + """ + lib = self._create_library(slug="hist-entries", title="History Entries") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + with freeze_time(datetime(2026, 2, 15, tzinfo=timezone.utc)): + self._set_library_block_olx(block_key, "

edit 1

") + with freeze_time(datetime(2026, 2, 20, tzinfo=timezone.utc)): + self._set_library_block_olx(block_key, "

edit 2

") + + with freeze_time(datetime(2026, 3, 1, tzinfo=timezone.utc)): + self._publish_library_block(block_key) + + history = self._get_block_publish_history(block_key) + assert len(history) == 1 + publish_log_uuid = history[0]["publish_log_uuid"] + + entries = self._get_block_publish_history_entries(block_key, publish_log_uuid) + assert len(entries) >= 1 + entry = entries[0] + assert "changed_by" in entry + assert "changed_at" in entry + assert "title" in entry + assert "action" in entry + + def test_publish_history_entries_unknown_uuid(self): + """ + Requesting entries for a publish_log_uuid unrelated to this component returns 404. + """ + lib = self._create_library(slug="hist-baduid", title="History Bad UUID") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + self._publish_library_block(block_key) + + fake_uuid = str(uuid.uuid4()) + self._get_block_publish_history_entries(block_key, fake_uuid, expect_response=404) + + def test_publish_history_nonexistent_block(self): + """ + Requesting publish history for a non-existent block returns 404. + """ + self._get_block_publish_history("lb:CL-TEST:hist-404:problem:nope", expect_response=404) + + def test_publish_history_permissions(self): + """ + A user without library access receives 403. + """ + lib = self._create_library(slug="hist-auth", title="History Auth") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + self._publish_library_block(block_key) + + unauthorized = UserFactory.create(username="noauth-hist", password="edx") + with self.as_user(unauthorized): + self._get_block_publish_history(block_key, expect_response=403) + class LibraryRestoreViewTestCase(ContentLibrariesRestApiTest): """ diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index ff1c12b1a4f3..592d88d59a69 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -79,6 +79,13 @@ path('publish/', blocks.LibraryBlockPublishView.as_view()), # Get the draft change history for this block path('draft_history/', blocks.LibraryComponentDraftHistoryView.as_view()), + # Get the publish history for this block (list of publish events) + path('publish_history/', blocks.LibraryComponentPublishHistoryView.as_view()), + # Get the draft change entries for a specific publish event (lazy) + path( + 'publish_history//entries/', + blocks.LibraryComponentPublishHistoryEntriesView.as_view() + ), # Future: discard changes for just this one block ])), # Containers are Sections, Subsections, and Units From e20f0a342926796ac916b8452bcf557bcb358a73 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 24 Mar 2026 13:20:20 -0500 Subject: [PATCH 3/4] feat: Add profile images to contributors list --- .../content_libraries/api/block_metadata.py | 22 +++++++- .../content_libraries/api/blocks.py | 51 ++++++++++++++++--- .../content_libraries/rest_api/blocks.py | 6 +-- .../content_libraries/rest_api/serializers.py | 20 ++++---- .../tests/test_content_libraries.py | 4 +- 5 files changed, 78 insertions(+), 25 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 0ae31baa9cb0..10158636e119 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -7,6 +7,7 @@ from django.utils.translation import gettext as _ from opaque_keys.edx.locator import LibraryUsageLocatorV2 +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user from .libraries import ( library_component_usage_key, PublishableItem, @@ -17,6 +18,7 @@ "LibraryXBlockMetadata", "LibraryXBlockStaticFile", "LibraryComponentDraftHistoryEntry", + "LibraryComponentContributor", "LibraryComponentPublishHistoryGroup", ] @@ -73,12 +75,28 @@ class LibraryComponentDraftHistoryEntry: """ One entry in the draft change history of a library component. """ - changed_by: object # AUTH_USER_MODEL instance or None + changed_by: LibraryComponentContributor | None changed_at: datetime title: str # title at time of change action: str # "edited" | "renamed" +@dataclass(frozen=True) +class LibraryComponentContributor: + """ + A contributor in a publish history group, with profile image URLs. + """ + username: str + profile_image_urls: dict # {"full": str, "large": str, "medium": str, "small": str} + + @classmethod + def from_user(cls, user, request=None) -> 'LibraryComponentContributor': + return cls( + username=user.username, + profile_image_urls=get_profile_image_urls_for_user(user, request), + ) + + @dataclass(frozen=True) class LibraryComponentPublishHistoryGroup: """ @@ -91,7 +109,7 @@ class LibraryComponentPublishHistoryGroup: publish_log_uuid: str published_by: object # AUTH_USER_MODEL instance or None published_at: datetime - contributors: list # list of AUTH_USER_MODEL, distinct authors of versions in this group + contributors: list[LibraryComponentContributor] # distinct authors of versions in this group contributors_count: int diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index d39d891fa6a1..28cfc3e50b8c 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -11,6 +11,7 @@ from uuid import uuid4 from django.conf import settings +from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import validate_unicode_slug from django.db import transaction @@ -59,6 +60,7 @@ LibraryBlockAlreadyExists, ) from .block_metadata import ( + LibraryComponentContributor, LibraryComponentDraftHistoryEntry, LibraryComponentPublishHistoryGroup, LibraryXBlockMetadata, @@ -83,6 +85,7 @@ from openedx.core.djangoapps.content_staging.api import StagedContentFileData log = logging.getLogger(__name__) +User = get_user_model() # The public API is only the following symbols: __all__ = [ @@ -199,7 +202,10 @@ def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=Fals return xblock_metadata -def get_library_component_draft_history(usage_key: LibraryUsageLocatorV2) -> list[LibraryComponentDraftHistoryEntry]: +def get_library_component_draft_history( + usage_key: LibraryUsageLocatorV2, + request=None, +) -> list[LibraryComponentDraftHistoryEntry]: """ Return the draft change history for a library component since its last publication, ordered from most recent to oldest. @@ -211,13 +217,16 @@ def get_library_component_draft_history(usage_key: LibraryUsageLocatorV2) -> lis except ObjectDoesNotExist as exc: raise ContentLibraryBlockNotFound(usage_key) from exc - records = content_api.get_entity_draft_history(component.publishable_entity) + records = list(content_api.get_entity_draft_history(component.publishable_entity)) + changed_by_list = _resolve_contributors( + (r.draft_change_log.changed_by for r in records), request + ) entries = [] - for record in records: + for record, changed_by in zip(records, changed_by_list): version = record.new_version if record.new_version is not None else record.old_version entries.append(LibraryComponentDraftHistoryEntry( - changed_by=record.draft_change_log.changed_by, + changed_by=changed_by, changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", action=_resolve_component_change_action(record.old_version, record.new_version), @@ -225,6 +234,24 @@ def get_library_component_draft_history(usage_key: LibraryUsageLocatorV2) -> lis return entries +def _resolve_contributors(users, request=None) -> list[LibraryComponentContributor | None]: + """ + Convert an iterable of User objects (possibly containing None) to a list of + LibraryComponentContributor. + """ + users_list = list(users) + user_pks = list({user.pk for user in users_list if user is not None}) + prefetched = { + user.pk: user + for user in User.objects.filter(pk__in=user_pks).select_related('profile') + } if user_pks else {} + return [ + LibraryComponentContributor.from_user(prefetched.get(user.pk, user), request) + if user else None + for user in users_list + ] + + def _resolve_component_change_action(old_version, new_version) -> str: if old_version and new_version and old_version.title != new_version.title: return "renamed" @@ -233,6 +260,7 @@ def _resolve_component_change_action(old_version, new_version) -> str: def get_library_component_publish_history( usage_key: LibraryUsageLocatorV2, + request=None, ) -> list[LibraryComponentPublishHistoryGroup]: """ Return the publish history of a library component as a list of groups. @@ -265,12 +293,14 @@ def get_library_component_publish_history( # new_version is None for soft-delete publishes (component deleted without a new draft version) new_version_num = pub_record.new_version.version_num if pub_record.new_version else None - contributors = list(content_api.get_entity_version_contributors( + raw_contributors = list(content_api.get_entity_version_contributors( entity, old_version_num=old_version_num, new_version_num=new_version_num, )) + contributors = [c for c in _resolve_contributors(raw_contributors, request) if c is not None] + groups.append(LibraryComponentPublishHistoryGroup( publish_log_uuid=str(pub_record.publish_log.uuid), published_by=pub_record.publish_log.published_by, @@ -285,6 +315,7 @@ def get_library_component_publish_history( def get_library_component_publish_history_entries( usage_key: LibraryUsageLocatorV2, publish_log_uuid: str, + request=None, ) -> list[LibraryComponentDraftHistoryEntry]: """ Return the individual draft change entries for a specific publish event. @@ -298,14 +329,18 @@ def get_library_component_publish_history_entries( except ObjectDoesNotExist as exc: raise ContentLibraryBlockNotFound(usage_key) from exc - records = content_api.get_entity_publish_history_entries( + records = list(content_api.get_entity_publish_history_entries( component.publishable_entity, publish_log_uuid + )) + changed_by_list = _resolve_contributors( + (r.draft_change_log.changed_by for r in records), request ) + entries = [] - for r in records: + for r, changed_by in zip(records, changed_by_list): version = r.new_version if r.new_version is not None else r.old_version entries.append(LibraryComponentDraftHistoryEntry( - changed_by=r.draft_change_log.changed_by, + changed_by=changed_by, changed_at=r.draft_change_log.changed_at, title=version.title if version is not None else "", action=_resolve_component_change_action(r.old_version, r.new_version), diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index 62bc4e183aaf..5ceb94c941af 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -155,7 +155,7 @@ def get(self, request, usage_key_str): """ 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) + history = api.get_library_component_draft_history(key, request=request) return Response(self.serializer_class(history, many=True).data) @@ -174,7 +174,7 @@ def get(self, request, usage_key_str): """ 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_publish_history(key) + history = api.get_library_component_publish_history(key, request=request) return Response(self.serializer_class(history, many=True).data) @@ -194,7 +194,7 @@ def get(self, request, usage_key_str, publish_log_uuid): key = LibraryUsageLocatorV2.from_string(usage_key_str) api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) try: - entries = api.get_library_component_publish_history_entries(key, publish_log_uuid) + entries = api.get_library_component_publish_history_entries(key, publish_log_uuid, request=request) except ObjectDoesNotExist as exc: raise NotFound(f"No publish event '{publish_log_uuid}' found for this component.") from exc return Response(self.serializer_class(entries, many=True).data) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index 31e24109671d..40951822eff4 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -181,20 +181,23 @@ class LibraryXBlockMetadataSerializer(PublishableItemSerializer): block_type = serializers.CharField(source="usage_key.block_type") +class LibraryComponentContributorSerializer(serializers.Serializer): + """ + Serializer for a contributor in a publish history group. + """ + username = serializers.CharField(read_only=True) + profile_image_urls = serializers.DictField(child=serializers.CharField(), read_only=True) + + class LibraryComponentDraftHistoryEntrySerializer(serializers.Serializer): """ Serializer for a single entry in the draft history of a library component. """ - changed_by = serializers.SerializerMethodField() + changed_by = LibraryComponentContributorSerializer(allow_null=True, read_only=True) 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 LibraryComponentPublishHistoryGroupSerializer(serializers.Serializer): """ @@ -203,15 +206,12 @@ class LibraryComponentPublishHistoryGroupSerializer(serializers.Serializer): publish_log_uuid = serializers.CharField(read_only=True) published_by = serializers.SerializerMethodField() published_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) - contributors = serializers.SerializerMethodField() + contributors = LibraryComponentContributorSerializer(many=True, read_only=True) contributors_count = serializers.IntegerField(read_only=True) def get_published_by(self, obj) -> str | None: return obj.published_by.username if obj.published_by else None - def get_contributors(self, obj) -> list[str]: - return [u.username for u in obj.contributors] - class LibraryXBlockTypeSerializer(serializers.Serializer): """ diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index cd791282ca46..5b325b119d32 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -1027,7 +1027,7 @@ def test_publish_history_after_single_publish(self): assert group["published_at"] == publish_time.isoformat().replace("+00:00", "Z") assert isinstance(group["publish_log_uuid"], str) assert group["contributors_count"] >= 1 - assert self.user.username in group["contributors"] + assert any(c["username"] == self.user.username for c in group["contributors"]) def test_publish_history_multiple_publishes(self): """ @@ -1069,7 +1069,7 @@ def test_publish_history_tracks_contributors(self): assert len(history) == 1 group = history[0] assert group["contributors_count"] >= 1 - assert self.user.username in group["contributors"] + assert any(c["username"] == self.user.username for c in group["contributors"]) def test_publish_history_entries(self): """ From 0fdbd38678656b93f1f96828f5bd1582581321cb Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 24 Mar 2026 18:28:31 -0500 Subject: [PATCH 4/4] feat: Add blockType to responses --- .../content_libraries/api/block_metadata.py | 15 ++++++----- .../content_libraries/api/blocks.py | 26 +++++++++++-------- .../content_libraries/rest_api/blocks.py | 4 +-- .../content_libraries/rest_api/serializers.py | 11 +++++--- 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 10158636e119..a71efe795197 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -17,7 +17,7 @@ __all__ = [ "LibraryXBlockMetadata", "LibraryXBlockStaticFile", - "LibraryComponentDraftHistoryEntry", + "LibraryComponentHistoryEntry", "LibraryComponentContributor", "LibraryComponentPublishHistoryGroup", ] @@ -71,14 +71,15 @@ def from_component(cls, library_key, component, associated_collections=None): @dataclass(frozen=True) -class LibraryComponentDraftHistoryEntry: +class LibraryComponentHistoryEntry: """ - One entry in the draft change history of a library component. + One entry in the history of a library component. """ changed_by: LibraryComponentContributor | None changed_at: datetime - title: str # title at time of change - action: str # "edited" | "renamed" + title: str # title at time of change + block_type: str + action: str # "edited" | "renamed" @dataclass(frozen=True) @@ -107,8 +108,10 @@ class LibraryComponentPublishHistoryGroup: previous publish and this one. """ publish_log_uuid: str - published_by: object # AUTH_USER_MODEL instance or None + published_by: object # AUTH_USER_MODEL instance or None published_at: datetime + title: str # title at time of publish + block_type: str contributors: list[LibraryComponentContributor] # distinct authors of versions in this group contributors_count: int diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 28cfc3e50b8c..99af550640bb 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -61,7 +61,7 @@ ) from .block_metadata import ( LibraryComponentContributor, - LibraryComponentDraftHistoryEntry, + LibraryComponentHistoryEntry, LibraryComponentPublishHistoryGroup, LibraryXBlockMetadata, LibraryXBlockStaticFile, @@ -205,7 +205,7 @@ def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=Fals def get_library_component_draft_history( usage_key: LibraryUsageLocatorV2, request=None, -) -> list[LibraryComponentDraftHistoryEntry]: +) -> list[LibraryComponentHistoryEntry]: """ Return the draft change history for a library component since its last publication, ordered from most recent to oldest. @@ -219,16 +219,17 @@ def get_library_component_draft_history( records = list(content_api.get_entity_draft_history(component.publishable_entity)) changed_by_list = _resolve_contributors( - (r.draft_change_log.changed_by for r in records), request + (record.draft_change_log.changed_by for record in records), request ) entries = [] for record, changed_by in zip(records, changed_by_list): version = record.new_version if record.new_version is not None else record.old_version - entries.append(LibraryComponentDraftHistoryEntry( + entries.append(LibraryComponentHistoryEntry( changed_by=changed_by, changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", + block_type=record.entity.component.component_type.name, action=_resolve_component_change_action(record.old_version, record.new_version), )) return entries @@ -306,6 +307,8 @@ def get_library_component_publish_history( published_by=pub_record.publish_log.published_by, published_at=pub_record.publish_log.published_at, contributors=contributors, + title=pub_record.new_version.title if pub_record.new_version else "", + block_type=pub_record.entity.component.component_type.name, contributors_count=len(contributors), )) @@ -316,7 +319,7 @@ def get_library_component_publish_history_entries( usage_key: LibraryUsageLocatorV2, publish_log_uuid: str, request=None, -) -> list[LibraryComponentDraftHistoryEntry]: +) -> list[LibraryComponentHistoryEntry]: """ Return the individual draft change entries for a specific publish event. @@ -333,17 +336,18 @@ def get_library_component_publish_history_entries( component.publishable_entity, publish_log_uuid )) changed_by_list = _resolve_contributors( - (r.draft_change_log.changed_by for r in records), request + (record.draft_change_log.changed_by for record in records), request ) entries = [] - for r, changed_by in zip(records, changed_by_list): - version = r.new_version if r.new_version is not None else r.old_version - entries.append(LibraryComponentDraftHistoryEntry( + for record, changed_by in zip(records, changed_by_list): + version = record.new_version if record.new_version is not None else record.old_version + entries.append(LibraryComponentHistoryEntry( changed_by=changed_by, - changed_at=r.draft_change_log.changed_at, + changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", - action=_resolve_component_change_action(r.old_version, r.new_version), + block_type=record.entity.component.component_type.name, + action=_resolve_component_change_action(record.old_version, record.new_version), )) return entries diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index 5ceb94c941af..9fc7bcc8d5d3 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -146,7 +146,7 @@ class LibraryComponentDraftHistoryView(APIView): """ View to get the draft change history of a library component. """ - serializer_class = serializers.LibraryComponentDraftHistoryEntrySerializer + serializer_class = serializers.LibraryComponentHistoryEntrySerializer @convert_exceptions def get(self, request, usage_key_str): @@ -184,7 +184,7 @@ class LibraryComponentPublishHistoryEntriesView(APIView): """ View to get the individual draft change entries for a specific publish event. """ - serializer_class = serializers.LibraryComponentDraftHistoryEntrySerializer + serializer_class = serializers.LibraryComponentHistoryEntrySerializer @convert_exceptions def get(self, request, usage_key_str, publish_log_uuid): diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index 40951822eff4..bcd08dc1fb50 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -189,14 +189,15 @@ class LibraryComponentContributorSerializer(serializers.Serializer): profile_image_urls = serializers.DictField(child=serializers.CharField(), read_only=True) -class LibraryComponentDraftHistoryEntrySerializer(serializers.Serializer): +class LibraryComponentHistoryEntrySerializer(serializers.Serializer): """ - Serializer for a single entry in the draft history of a library component. + Serializer for a single entry in the history of a library component. """ changed_by = LibraryComponentContributorSerializer(allow_null=True, read_only=True) changed_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) - title = serializers.CharField() - action = serializers.CharField() + title = serializers.CharField(read_only=True) + block_type = serializers.CharField(read_only=True) + action = serializers.CharField(read_only=True) class LibraryComponentPublishHistoryGroupSerializer(serializers.Serializer): @@ -204,8 +205,10 @@ class LibraryComponentPublishHistoryGroupSerializer(serializers.Serializer): Serializer for a publish event summary in the publish history of a library component. """ publish_log_uuid = serializers.CharField(read_only=True) + title = serializers.CharField(read_only=True) published_by = serializers.SerializerMethodField() published_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + block_type = serializers.CharField(read_only=True) contributors = LibraryComponentContributorSerializer(many=True, read_only=True) contributors_count = serializers.IntegerField(read_only=True)