diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py
index f117d2762949..0ae31baa9cb0 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,8 @@
__all__ = [
"LibraryXBlockMetadata",
"LibraryXBlockStaticFile",
+ "LibraryComponentDraftHistoryEntry",
+ "LibraryComponentPublishHistoryGroup",
]
@@ -49,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,
@@ -64,6 +68,33 @@ 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 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 dc0913d0fdc7..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 LibraryXBlockMetadata, LibraryXBlockStaticFile
+from .block_metadata import (
+ LibraryComponentDraftHistoryEntry,
+ LibraryComponentPublishHistoryGroup,
+ LibraryXBlockMetadata,
+ LibraryXBlockStaticFile,
+)
from .containers import (
create_container,
get_container,
@@ -98,6 +103,9 @@
"add_library_block_static_asset_file",
"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",
]
@@ -191,6 +199,120 @@ 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)
+
+ 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=version.title if version is not None else "",
+ action=_resolve_component_change_action(record.old_version, record.new_version),
+ ))
+ return entries
+
+
+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.
@@ -682,7 +804,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/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 86bd8f6112dd..62bc4e183aaf 100644
--- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py
+++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py
@@ -140,6 +140,66 @@ 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 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 87a5dd3e3b6f..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
@@ -180,6 +181,38 @@ 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 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 9dc12e943156..592d88d59a69 100644
--- a/openedx/core/djangoapps/content_libraries/urls.py
+++ b/openedx/core/djangoapps/content_libraries/urls.py
@@ -77,6 +77,15 @@
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()),
+ # 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