From 9e5a1c5fee94cba19d3c4e481ea48a7571f0812a Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 16 Mar 2026 18:18:12 -0500 Subject: [PATCH 1/2] 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/2] 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