From 9e5a1c5fee94cba19d3c4e481ea48a7571f0812a Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 16 Mar 2026 18:18:12 -0500 Subject: [PATCH 01/31] 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 02/31] 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 03/31] 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 04/31] 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) From 1688454a685277089a7c372fe39d756075ba742b Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 25 Mar 2026 21:09:54 -0500 Subject: [PATCH 05/31] feat: get_library_component_creation_entry added --- .../content_libraries/api/blocks.py | 38 +++++++++++++ .../content_libraries/rest_api/blocks.py | 21 +++++++ .../content_libraries/tests/base.py | 5 ++ .../tests/test_content_libraries.py | 56 +++++++++++++++++++ .../core/djangoapps/content_libraries/urls.py | 2 + 5 files changed, 122 insertions(+) diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 99af550640bb..f9c5cf78f0a6 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -109,6 +109,7 @@ "get_library_component_draft_history", "get_library_component_publish_history", "get_library_component_publish_history_entries", + "get_library_component_creation_entry", ] @@ -352,6 +353,43 @@ def get_library_component_publish_history_entries( return entries +def get_library_component_creation_entry( + usage_key: LibraryUsageLocatorV2, + request=None, +) -> LibraryComponentHistoryEntry | None: + """ + Return the creation entry for a library component. + + This is a single LibraryComponentHistoryEntry representing the moment the + component was first created (version_num=1). Returns None if the component + has no versions yet. + + 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 + + first_version = ( + component.publishable_entity.versions + .filter(version_num=1) + .select_related("created_by") + .first() + ) + if first_version is None: + return None + + changed_by_list = _resolve_contributors([first_version.created_by], request) + return LibraryComponentHistoryEntry( + changed_by=changed_by_list[0], + changed_at=first_version.created, + title=first_version.title, + block_type=component.component_type.name, + action="created", + ) + + 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/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index 9fc7bcc8d5d3..551cb6c13eef 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -200,6 +200,27 @@ def get(self, request, usage_key_str, publish_log_uuid): return Response(self.serializer_class(entries, many=True).data) +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryComponentCreationEntryView(APIView): + """ + View to get the creation entry for a library component. + """ + serializer_class = serializers.LibraryComponentHistoryEntrySerializer + + @convert_exceptions + def get(self, request, usage_key_str): + """ + Get the creation entry for a library component (the moment it was first saved). + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + entry = api.get_library_component_creation_entry(key, request=request) + if entry is None: + return Response(None) + return Response(self.serializer_class(entry).data) + + @method_decorator(non_atomic_requests, name="dispatch") @view_auth_classes() class LibraryBlockAssetListView(APIView): diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index f857a92ce5c6..b5d9c22d59d7 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -41,6 +41,7 @@ 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_CREATION_ENTRY = URL_LIB_BLOCK + 'creation_entry/' 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 @@ -340,6 +341,10 @@ def _get_block_publish_history_entries(self, block_key, publish_log_uuid, expect ) return self._api('get', url, None, expect_response) + def _get_block_creation_entry(self, block_key, expect_response=200): + """ Get the creation entry for a block (the moment it was first saved) """ + return self._api('get', URL_LIB_BLOCK_CREATION_ENTRY.format(block_key=block_key), 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 5b325b119d32..1bda75cba847 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -1134,6 +1134,62 @@ def test_publish_history_permissions(self): with self.as_user(unauthorized): self._get_block_publish_history(block_key, expect_response=403) + def test_creation_entry_returns_first_version(self): + """ + The creation entry corresponds to the first time the block was saved, + with action='created' and the correct fields populated. + """ + lib = self._create_library(slug="creation-entry-basic", title="Creation Entry Basic") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + entry = self._get_block_creation_entry(block_key) + + assert entry is not None + assert entry["action"] == "created" + assert entry["block_type"] == "problem" + assert "changed_at" in entry + assert "title" in entry + assert "changed_by" in entry + + def test_creation_entry_unchanged_after_edits(self): + """ + Subsequent edits and publishes do not affect the creation entry — it + always reflects the first saved version. + """ + lib = self._create_library(slug="creation-entry-stable", title="Creation Entry Stable") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + # Record the creation entry before any edits + entry_before = self._get_block_creation_entry(block_key) + + self._set_library_block_olx(block_key, "

edited

") + self._publish_library_block(block_key) + + entry_after = self._get_block_creation_entry(block_key) + + assert entry_after["changed_at"] == entry_before["changed_at"] + assert entry_after["action"] == "created" + + def test_creation_entry_nonexistent_block(self): + """ + Requesting the creation entry for a non-existent block returns 404. + """ + self._get_block_creation_entry("lb:CL-TEST:creation-404:problem:nope", expect_response=404) + + def test_creation_entry_permissions(self): + """ + A user without library access receives 403. + """ + lib = self._create_library(slug="creation-entry-auth", title="Creation Entry Auth") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + unauthorized = UserFactory.create(username="noauth-creation", password="edx") + with self.as_user(unauthorized): + self._get_block_creation_entry(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 592d88d59a69..bd722477c582 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -86,6 +86,8 @@ 'publish_history//entries/', blocks.LibraryComponentPublishHistoryEntriesView.as_view() ), + # Get the creation entry for this block + path('creation_entry/', blocks.LibraryComponentCreationEntryView.as_view()), # Future: discard changes for just this one block ])), # Containers are Sections, Subsections, and Units From c6df70fac736e8adeae05816c0e02c80feaa563e Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 27 Mar 2026 13:17:29 -0500 Subject: [PATCH 06/31] feat: get_library_container_draft_history function added --- .../content_libraries/api/block_metadata.py | 55 +++++++-- .../content_libraries/api/blocks.py | 64 ++++------- .../content_libraries/api/containers.py | 62 +++++++++- .../content_libraries/rest_api/blocks.py | 8 +- .../content_libraries/rest_api/containers.py | 22 ++++ .../content_libraries/rest_api/serializers.py | 14 +-- .../content_libraries/tests/base.py | 10 ++ .../tests/test_containers.py | 106 ++++++++++++++++++ .../core/djangoapps/content_libraries/urls.py | 2 + 9 files changed, 275 insertions(+), 68 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index a71efe795197..ddb6768255a1 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -6,6 +6,7 @@ from datetime import datetime from django.utils.translation import gettext as _ +from django.contrib.auth import get_user_model 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 ( @@ -17,12 +18,12 @@ __all__ = [ "LibraryXBlockMetadata", "LibraryXBlockStaticFile", - "LibraryComponentHistoryEntry", - "LibraryComponentContributor", - "LibraryComponentPublishHistoryGroup", + "LibraryHistoryEntry", + "LibraryHistoryContributor", + "LibraryPublishHistoryGroup", ] - +User = get_user_model() @dataclass(frozen=True, kw_only=True) class LibraryXBlockMetadata(PublishableItem): """ @@ -71,11 +72,11 @@ def from_component(cls, library_key, component, associated_collections=None): @dataclass(frozen=True) -class LibraryComponentHistoryEntry: +class LibraryHistoryEntry: """ One entry in the history of a library component. """ - changed_by: LibraryComponentContributor | None + changed_by: LibraryHistoryContributor | None changed_at: datetime title: str # title at time of change block_type: str @@ -83,7 +84,7 @@ class LibraryComponentHistoryEntry: @dataclass(frozen=True) -class LibraryComponentContributor: +class LibraryHistoryContributor: """ A contributor in a publish history group, with profile image URLs. """ @@ -91,7 +92,7 @@ class LibraryComponentContributor: profile_image_urls: dict # {"full": str, "large": str, "medium": str, "small": str} @classmethod - def from_user(cls, user, request=None) -> 'LibraryComponentContributor': + def from_user(cls, user, request=None) -> 'LibraryHistoryContributor': return cls( username=user.username, profile_image_urls=get_profile_image_urls_for_user(user, request), @@ -99,11 +100,11 @@ def from_user(cls, user, request=None) -> 'LibraryComponentContributor': @dataclass(frozen=True) -class LibraryComponentPublishHistoryGroup: +class LibraryPublishHistoryGroup: """ - Summary of a publish event for a library component. + Summary of a publish event for a library item. - Each instance represents one PublishLogRecord for the component, and + Each instance represents one PublishLogRecord for the item, and includes the set of contributors who authored draft changes between the previous publish and this one. """ @@ -112,7 +113,7 @@ class LibraryComponentPublishHistoryGroup: published_at: datetime title: str # title at time of publish block_type: str - contributors: list[LibraryComponentContributor] # distinct authors of versions in this group + contributors: list[LibraryHistoryContributor] # distinct authors of versions in this group contributors_count: int @@ -129,3 +130,33 @@ class LibraryXBlockStaticFile: url: str # Size in bytes size: int + + +def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor | None]: + """ + Convert an iterable of User objects (possibly containing None) to a list of + LibraryHistoryContributor. + """ + 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 [ + LibraryHistoryContributor.from_user(prefetched.get(user.pk, user), request) + if user else None + for user in users_list + ] + + +def resolve_change_action(old_version, new_version) -> str: + """ + Derive a human-readable action label from a draft history record's versions. + + Returns "renamed" when both versions exist and the title changed between + them; otherwise returns "edited" as the default action. + """ + if old_version and new_version and old_version.title != new_version.title: + return "renamed" + return "edited" diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index f9c5cf78f0a6..1f93f2ab34a4 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -11,7 +11,6 @@ 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 @@ -60,11 +59,12 @@ LibraryBlockAlreadyExists, ) from .block_metadata import ( - LibraryComponentContributor, - LibraryComponentHistoryEntry, - LibraryComponentPublishHistoryGroup, + LibraryHistoryEntry, + LibraryPublishHistoryGroup, LibraryXBlockMetadata, LibraryXBlockStaticFile, + resolve_contributors, + resolve_change_action, ) from .containers import ( create_container, @@ -85,7 +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__ = [ @@ -206,7 +206,7 @@ def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=Fals def get_library_component_draft_history( usage_key: LibraryUsageLocatorV2, request=None, -) -> list[LibraryComponentHistoryEntry]: +) -> list[LibraryHistoryEntry]: """ Return the draft change history for a library component since its last publication, ordered from most recent to oldest. @@ -219,51 +219,27 @@ def get_library_component_draft_history( raise ContentLibraryBlockNotFound(usage_key) from exc records = list(content_api.get_entity_draft_history(component.publishable_entity)) - changed_by_list = _resolve_contributors( + changed_by_list = resolve_contributors( (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(LibraryComponentHistoryEntry( + entries.append(LibraryHistoryEntry( 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), + action=resolve_change_action(record.old_version, record.new_version), )) 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" - return "edited" - - def get_library_component_publish_history( usage_key: LibraryUsageLocatorV2, request=None, -) -> list[LibraryComponentPublishHistoryGroup]: +) -> list[LibraryPublishHistoryGroup]: """ Return the publish history of a library component as a list of groups. @@ -301,9 +277,9 @@ def get_library_component_publish_history( new_version_num=new_version_num, )) - contributors = [c for c in _resolve_contributors(raw_contributors, request) if c is not None] + contributors = [c for c in resolve_contributors(raw_contributors, request) if c is not None] - groups.append(LibraryComponentPublishHistoryGroup( + groups.append(LibraryPublishHistoryGroup( 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, @@ -320,7 +296,7 @@ def get_library_component_publish_history_entries( usage_key: LibraryUsageLocatorV2, publish_log_uuid: str, request=None, -) -> list[LibraryComponentHistoryEntry]: +) -> list[LibraryHistoryEntry]: """ Return the individual draft change entries for a specific publish event. @@ -336,19 +312,19 @@ def get_library_component_publish_history_entries( records = list(content_api.get_entity_publish_history_entries( component.publishable_entity, publish_log_uuid )) - changed_by_list = _resolve_contributors( + changed_by_list = resolve_contributors( (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(LibraryComponentHistoryEntry( + entries.append(LibraryHistoryEntry( 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), + action=resolve_change_action(record.old_version, record.new_version), )) return entries @@ -356,11 +332,11 @@ def get_library_component_publish_history_entries( def get_library_component_creation_entry( usage_key: LibraryUsageLocatorV2, request=None, -) -> LibraryComponentHistoryEntry | None: +) -> LibraryHistoryEntry | None: """ Return the creation entry for a library component. - This is a single LibraryComponentHistoryEntry representing the moment the + This is a single LibraryHistoryEntry representing the moment the component was first created (version_num=1). Returns None if the component has no versions yet. @@ -380,8 +356,8 @@ def get_library_component_creation_entry( if first_version is None: return None - changed_by_list = _resolve_contributors([first_version.created_by], request) - return LibraryComponentHistoryEntry( + changed_by_list = resolve_contributors([first_version.created_by], request) + return LibraryHistoryEntry( changed_by=changed_by_list[0], changed_at=first_version.created, title=first_version.title, diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 54740d130321..a2b3bea58e90 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -30,7 +30,12 @@ from .. import tasks from ..models import ContentLibrary -from .block_metadata import LibraryXBlockMetadata +from .block_metadata import ( + LibraryXBlockMetadata, + LibraryHistoryEntry, + resolve_contributors, + resolve_change_action, +) from .container_metadata import ( ContainerHierarchy, ContainerMetadata, @@ -61,6 +66,7 @@ "get_library_object_hierarchy", "copy_container", "library_container_locator", + "get_library_container_draft_history", ] log = logging.getLogger(__name__) @@ -639,3 +645,57 @@ def get_library_object_hierarchy( https://github.com/openedx/edx-platform/pull/36813#issuecomment-3136631767 """ return ContainerHierarchy.create_from_library_object_key(object_key) + + +def get_library_container_draft_history( + container_key: LibraryContainerLocator, + request=None, +) -> list[LibraryHistoryEntry]: + """ + [ 🛑 UNSTABLE ] Return the combined draft history for a container and all of its descendant + components, sorted from most-recent to oldest. + + Each entry describes a single change log record: who made the change, when, + what the title was at that point. + """ + container = get_container_from_key(container_key) + # Collect entity IDs for all components nested inside this container. + component_entity_ids = content_api.get_descendant_component_entity_ids(container) + + results: list[LibraryHistoryEntry] = [] + # Process the container itself first, then each descendant component. + for item_id in [container.pk] + component_entity_ids: + records = content_api.get_entity_draft_history(item_id) + # Resolve user profiles for all authors in one batch to avoid N+1 queries. + changed_by_list = resolve_contributors( + (record.draft_change_log.changed_by for record in records), request + ) + + entries = [] + for record, changed_by in zip(records, changed_by_list): + # Use the new version when available; fall back to the old version + # (e.g. for delete records where new_version is None). + version = record.new_version if record.new_version is not None else record.old_version + try: + block_type = record.entity.component.component_type.name + except Component.DoesNotExist: + # The entity is a Container, which has no component_type. + # TODO: update openedx-core to expose container_type so we can + # populate block_type for containers as well. + block_type = None + entries.append(LibraryHistoryEntry( + changed_by=changed_by, + changed_at=record.draft_change_log.changed_at, + title=version.title if version is not None else "", + block_type=block_type, + action=resolve_change_action(record.old_version, record.new_version), + )) + + results.extend(entries) + + # Return all entries sorted newest-first across the container and its children. + results.sort( + key=lambda entry: entry.changed_at, + reverse=True, + ) + return results diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index 551cb6c13eef..bb738c60be56 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.LibraryComponentHistoryEntrySerializer + serializer_class = serializers.LibraryHistoryEntrySerializer @convert_exceptions def get(self, request, usage_key_str): @@ -165,7 +165,7 @@ class LibraryComponentPublishHistoryView(APIView): """ View to get the publish history of a library component as a list of publish events. """ - serializer_class = serializers.LibraryComponentPublishHistoryGroupSerializer + serializer_class = serializers.LibraryPublishHistoryGroupSerializer @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.LibraryComponentHistoryEntrySerializer + serializer_class = serializers.LibraryHistoryEntrySerializer @convert_exceptions def get(self, request, usage_key_str, publish_log_uuid): @@ -206,7 +206,7 @@ class LibraryComponentCreationEntryView(APIView): """ View to get the creation entry for a library component. """ - serializer_class = serializers.LibraryComponentHistoryEntrySerializer + serializer_class = serializers.LibraryHistoryEntrySerializer @convert_exceptions def get(self, request, usage_key_str): diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index 4b368e8eb9b2..c9e6aec84c95 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -436,3 +436,25 @@ def get(self, request: RestRequest, container_key: LibraryContainerLocator) -> R ) hierarchy = api.get_library_object_hierarchy(container_key) return Response(self.serializer_class(hierarchy).data) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerDraftHistoryView(GenericAPIView): + """ + View to get the draft change history of a library container. + """ + serializer_class = serializers.LibraryHistoryEntrySerializer + + @convert_exceptions + def get(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response: + """ + Get the draft change history for a library containers since its last publication. + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + history = api.get_library_container_draft_history(container_key, request=request) + return Response(self.serializer_class(history, 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 bcd08dc1fb50..4f1b5adbb637 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -181,7 +181,7 @@ class LibraryXBlockMetadataSerializer(PublishableItemSerializer): block_type = serializers.CharField(source="usage_key.block_type") -class LibraryComponentContributorSerializer(serializers.Serializer): +class LibraryHistoryContributorSerializer(serializers.Serializer): """ Serializer for a contributor in a publish history group. """ @@ -189,27 +189,27 @@ class LibraryComponentContributorSerializer(serializers.Serializer): profile_image_urls = serializers.DictField(child=serializers.CharField(), read_only=True) -class LibraryComponentHistoryEntrySerializer(serializers.Serializer): +class LibraryHistoryEntrySerializer(serializers.Serializer): """ - Serializer for a single entry in the history of a library component. + Serializer for a single entry in the history of a library item. """ - changed_by = LibraryComponentContributorSerializer(allow_null=True, read_only=True) + changed_by = LibraryHistoryContributorSerializer(allow_null=True, read_only=True) changed_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) title = serializers.CharField(read_only=True) block_type = serializers.CharField(read_only=True) action = serializers.CharField(read_only=True) -class LibraryComponentPublishHistoryGroupSerializer(serializers.Serializer): +class LibraryPublishHistoryGroupSerializer(serializers.Serializer): """ - Serializer for a publish event summary in the publish history of a library component. + Serializer for a publish event summary in the publish history of a library item. """ 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 = LibraryHistoryContributorSerializer(many=True, read_only=True) contributors_count = serializers.IntegerField(read_only=True) def get_published_by(self, obj) -> str | None: diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index b5d9c22d59d7..1687fcfcb875 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -53,6 +53,7 @@ URL_LIB_CONTAINER_COLLECTIONS = URL_LIB_CONTAINER + 'collections/' # Handle associated collections URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to the specified container + children URL_LIB_CONTAINER_COPY = URL_LIB_CONTAINER + 'copy/' # Copy the specified container to the clipboard +URL_LIB_CONTAINER_DRAFT_HISTORY = URL_LIB_CONTAINER + 'draft_history/' # Draft change history for a container URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' # Get a collection in this library URL_LIB_COLLECTION_ITEMS = URL_LIB_COLLECTION + 'items/' # Get a collection in this library @@ -525,6 +526,15 @@ def _publish_container(self, container_key: ContainerKey | str, expect_response= """ Publish all changes in the specified container + children """ return self._api('post', URL_LIB_CONTAINER_PUBLISH.format(container_key=container_key), None, expect_response) + def _get_container_draft_history(self, container_key: ContainerKey | str, expect_response=200): + """ Get the draft change history for a container and its descendants since the last publication """ + return self._api( + 'get', + URL_LIB_CONTAINER_DRAFT_HISTORY.format(container_key=container_key), + None, + expect_response, + ) + def _copy_container(self, container_key: ContainerKey | str, expect_response=200): """ Copy the specified container to the clipboard """ return self._api('post', URL_LIB_CONTAINER_COPY.format(container_key=container_key), None, expect_response) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 31ac143d0640..0188914594e1 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -1012,3 +1012,109 @@ def test_publish_section(self) -> None: assert c2_units_after[1]["id"] == subsection_4["id"] assert c2_units_after[1]["has_unpublished_changes"] # unaffected assert c2_units_after[1]["published_by"] is None + + def test_container_draft_history_empty_after_publish(self): + """ + A container with no unpublished changes since its last publish has an empty draft history. + """ + unit = self._create_container(self.lib["id"], "unit", display_name="History Unit", slug=None) + self._publish_container(unit["id"]) + + history = self._get_container_draft_history(unit["id"]) + assert history == [] + + def test_container_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. + """ + unit = self._create_container(self.lib["id"], "unit", display_name="History Unit Edits", slug=None) + self._publish_container(unit["id"]) + + edit1_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc) + with freeze_time(edit1_time): + self._update_container(unit["id"], display_name="History Unit Edits v2") + + edit2_time = datetime(2026, 4, 2, 10, 0, 0, tzinfo=timezone.utc) + with freeze_time(edit2_time): + self._update_container(unit["id"], display_name="History Unit Edits v3") + + history = self._get_container_draft_history(unit["id"]) + 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_container_draft_history_includes_descendant_components(self): + """ + The history of a container includes entries from its descendant components, + merged and sorted newest-first. + """ + unit = self._create_container(self.lib["id"], "unit", display_name="History Unit Children", slug=None) + block = self._add_block_to_library(self.lib["id"], "problem", "hist-prob", can_stand_alone=False) + self._add_container_children(unit["id"], children_ids=[block["id"]]) + self._publish_container(unit["id"]) + + container_edit_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc) + with freeze_time(container_edit_time): + self._update_container(unit["id"], display_name="History Unit Children v2") + + block_edit_time = datetime(2026, 4, 2, 10, 0, 0, tzinfo=timezone.utc) + with freeze_time(block_edit_time): + self._set_library_block_olx(block["id"], "

edited

") + + history = self._get_container_draft_history(unit["id"]) + changed_at_list = [entry["changed_at"] for entry in history] + # Both the container edit and the block edit should appear in the history. + assert block_edit_time.isoformat().replace("+00:00", "Z") in changed_at_list + assert container_edit_time.isoformat().replace("+00:00", "Z") in changed_at_list + # History is sorted newest-first, so the block edit should come before the container edit. + assert changed_at_list.index(block_edit_time.isoformat().replace("+00:00", "Z")) < \ + changed_at_list.index(container_edit_time.isoformat().replace("+00:00", "Z")) + + def test_container_draft_history_action_renamed(self): + """ + When the title changes, the action is 'renamed'. + """ + unit = self._create_container(self.lib["id"], "unit", display_name="Original Name", slug=None) + self._publish_container(unit["id"]) + self._update_container(unit["id"], display_name="New Name") + + history = self._get_container_draft_history(unit["id"]) + assert len(history) >= 1 + assert history[0]["action"] == "renamed" + + def test_container_draft_history_cleared_after_publish(self): + """ + After publishing, the draft history resets to empty. + """ + unit = self._create_container(self.lib["id"], "unit", display_name="Clear History Unit", slug=None) + self._publish_container(unit["id"]) + self._update_container(unit["id"], display_name="Updated Name") + assert len(self._get_container_draft_history(unit["id"])) >= 1 + + self._publish_container(unit["id"]) + assert self._get_container_draft_history(unit["id"]) == [] + + def test_container_draft_history_nonexistent_container(self): + """ + Requesting draft history for a non-existent container returns 404. + """ + self._get_container_draft_history( + "lct:CL-TEST:containers:unit:nonexistent", + expect_response=404, + ) + + def test_container_draft_history_permissions(self): + """ + A user without library access receives 403. + """ + unit = self._create_container(self.lib["id"], "unit", display_name="Auth Unit", slug=None) + self._update_container(unit["id"], display_name="Updated Auth Unit") + + unauthorized = UserFactory.create(username="noauth-container-hist", password="edx") + with self.as_user(unauthorized): + self._get_container_draft_history(unit["id"], expect_response=403) diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index bd722477c582..d3899950d977 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -105,6 +105,8 @@ # Publish a container (or reset to last published) path('publish/', containers.LibraryContainerPublishView.as_view()), path('copy/', containers.LibraryContainerCopyView.as_view()), + # Get the draft change history for this container + path('draft_history/', containers.LibraryContainerDraftHistoryView.as_view()), ])), re_path(r'^lti/1.3/', include([ path('login/', libraries.LtiToolLoginView.as_view(), name='lti-login'), From c14bb360759b77b62b45c9ba75d039781a754f59 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 13 Apr 2026 12:44:47 -0500 Subject: [PATCH 07/31] feat: add container publish history endpoint and unify entries endpoint * Add get_library_container_publish_history and get_library_container_publish_history_entries to the containers API, returning groups (container + descendant components) sorted newest-first * Add entity_key field to LibraryPublishHistoryGroup so the frontend knows which entries endpoint to call per group; block_type is now nullable to support containers * Replace per-block publish_history//entries/ route with a single library-level publish_history_entries/ endpoint that accepts entity_key and publish_log_uuid as query params and routes to the correct handler --- .../content_libraries/api/block_metadata.py | 5 +- .../content_libraries/api/blocks.py | 1 + .../content_libraries/api/containers.py | 109 +++++++++++++++++- .../content_libraries/rest_api/blocks.py | 43 +++++-- .../content_libraries/rest_api/containers.py | 27 +++++ .../content_libraries/rest_api/serializers.py | 3 +- .../content_libraries/tests/base.py | 18 +-- .../tests/test_containers.py | 16 ++- .../tests/test_content_libraries.py | 15 ++- .../core/djangoapps/content_libraries/urls.py | 9 +- 10 files changed, 209 insertions(+), 37 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 181bf3c89050..a9f81652eb67 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -77,7 +77,7 @@ class LibraryHistoryEntry: changed_by: LibraryHistoryContributor | None changed_at: datetime title: str # title at time of change - block_type: str + block_type: str | None action: str # "edited" | "renamed" @@ -110,9 +110,10 @@ class LibraryPublishHistoryGroup: published_by: object # AUTH_USER_MODEL instance or None published_at: datetime title: str # title at time of publish - block_type: str + block_type: str | None contributors: list[LibraryHistoryContributor] # distinct authors of versions in this group contributors_count: int + entity_key: str # str(usage_key) for components, str(container_key) for containers @dataclass(frozen=True) diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 4ffe33af0031..200deecfb321 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -285,6 +285,7 @@ def get_library_component_publish_history( 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), + entity_key=str(usage_key), )) return groups diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 3e9964055345..328d51dccb23 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -10,10 +10,11 @@ from uuid import uuid4 from django.db import transaction +from django.core.exceptions import ObjectDoesNotExist from django.utils.text import slugify from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content import api as content_api -from openedx_content.models_api import Container, Unit +from openedx_content.models_api import Container, Unit, Component from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData, LibraryContainerData from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, @@ -30,6 +31,7 @@ from .block_metadata import ( LibraryXBlockMetadata, LibraryHistoryEntry, + LibraryPublishHistoryGroup, resolve_contributors, resolve_change_action, ) @@ -64,6 +66,8 @@ "copy_container", "library_container_locator", "get_library_container_draft_history", + "get_library_container_publish_history", + "get_library_container_publish_history_entries", ] log = logging.getLogger(__name__) @@ -569,3 +573,106 @@ def get_library_container_draft_history( reverse=True, ) return results + + +def get_library_container_publish_history( + container_key: LibraryContainerLocator, + request=None, +) -> list[LibraryPublishHistoryGroup]: + """ + [ 🛑 UNSTABLE ] Return the publish history of a container as a list of groups. + + Each group represents one publish event for ONE entity (the container itself + or a descendant component). When the container and components are published + together, each entity produces its own independent group — the component + group is identical to what get_library_component_publish_history returns + for that component. + + Each group includes entity_key: str(container_key) for the container itself, + or str(usage_key) for a descendant component. The frontend uses entity_key + to call the correct entries endpoint when expanding a group. + + Groups are ordered most-recent-first. Returns [] if nothing has been published. + """ + container = get_container_from_key(container_key) + component_entity_ids = content_api.get_descendant_component_entity_ids(container) + + groups = [] + for entity_id in [container.pk] + component_entity_ids: + for pub_record in content_api.get_entity_publish_history(entity_id): + old_version_num = pub_record.old_version.version_num if pub_record.old_version else 0 + new_version_num = pub_record.new_version.version_num if pub_record.new_version else None + + raw_contributors = list(content_api.get_entity_version_contributors( + entity_id, + 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] + + try: + component = pub_record.entity.component + block_type = component.component_type.name + entity_key = str(LibraryUsageLocatorV2( # type: ignore[abstract] + lib_key=container_key.lib_key, + block_type=component.component_type.name, + usage_id=component.local_key, + )) + except Component.DoesNotExist: + block_type = None + entity_key = str(container_key) + + groups.append(LibraryPublishHistoryGroup( + 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, + title=pub_record.new_version.title if pub_record.new_version else "", + block_type=block_type, + contributors_count=len(contributors), + entity_key=entity_key, + )) + + groups.sort(key=lambda g: g.published_at, reverse=True) + return groups + + +def get_library_container_publish_history_entries( + container_key: LibraryContainerLocator, + publish_log_uuid: str, + request=None, +) -> list[LibraryHistoryEntry]: + """ + [ 🛑 UNSTABLE ] Return the individual draft change entries for the container + entity in a specific publish event. + + Only returns entries for the container itself (not descendant components). + For component groups, use get_library_component_publish_history_entries. + Returns [] if the container was not part of this publish event. + """ + container = get_container_from_key(container_key) + + try: + records = list(content_api.get_entity_publish_history_entries( + container.pk, publish_log_uuid + )) + except ObjectDoesNotExist: + return [] + + changed_by_list = resolve_contributors( + (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(LibraryHistoryEntry( + changed_by=changed_by, + changed_at=record.draft_change_log.changed_at, + title=version.title if version is not None else "", + block_type=None, + action=resolve_change_action(record.old_version, record.new_version), + )) + + entries.sort(key=lambda entry: entry.changed_at, reverse=True) + 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 bb738c60be56..0406fcdc3d3a 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -8,7 +8,8 @@ from django.urls import reverse from django.utils.decorators import method_decorator from drf_yasg.utils import swagger_auto_schema -from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_authz.constants import permissions as authz_permissions from openedx_content import api as content_api from rest_framework import status @@ -180,23 +181,47 @@ def get(self, request, usage_key_str): @method_decorator(non_atomic_requests, name="dispatch") @view_auth_classes() -class LibraryComponentPublishHistoryEntriesView(APIView): +class LibraryPublishHistoryEntriesView(APIView): """ - View to get the individual draft change entries for a specific publish event. + Unified view to get individual draft change entries for a specific publish event. + + Accepts any library entity key (component usage_key or container key) via the + entity_key query parameter and routes to the appropriate API function. """ serializer_class = serializers.LibraryHistoryEntrySerializer @convert_exceptions - def get(self, request, usage_key_str, publish_log_uuid): + def get(self, request, lib_key_str): """ Get the draft change entries for a specific publish event, ordered most-recent-first. + + Query parameters: + - entity_key: the usage_key (component) or container_key (container) + - publish_log_uuid: UUID of the publish event """ - key = LibraryUsageLocatorV2.from_string(usage_key_str) - api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + lib_key = LibraryLocatorV2.from_string(lib_key_str) + api.require_permission_for_library_key(lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + entity_key_str = request.query_params.get("entity_key", "") + publish_log_uuid = request.query_params.get("publish_log_uuid", "") + if not entity_key_str or not publish_log_uuid: + return Response({"error": "entity_key and publish_log_uuid are required."}, status=400) + try: - 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 + usage_key = LibraryUsageLocatorV2.from_string(entity_key_str) + entries = api.get_library_component_publish_history_entries( + usage_key, publish_log_uuid, request=request + ) + except ObjectDoesNotExist: + entries = [] + except (InvalidKeyError, AttributeError): + try: + container_key = LibraryContainerLocator.from_string(entity_key_str) + entries = api.get_library_container_publish_history_entries( + container_key, publish_log_uuid, request=request + ) + except (InvalidKeyError, AttributeError): + return Response({"error": f"Invalid entity_key: {entity_key_str!r}"}, status=400) + return Response(self.serializer_class(entries, many=True).data) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index 0013be7bada4..008dcdb940e0 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -458,3 +458,30 @@ def get(self, request: RestRequest, container_key: LibraryContainerLocator) -> R ) history = api.get_library_container_draft_history(container_key, request=request) return Response(self.serializer_class(history, many=True).data) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerPublishHistoryView(GenericAPIView): + """ + View to get the publish history of a library container as a list of publish events. + """ + serializer_class = serializers.LibraryPublishHistoryGroupSerializer + + @convert_exceptions + def get(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response: + """ + Get the publish history for a library container, ordered most-recent-first. + + Each group in the response represents one publish event for one entity + (the container itself or a descendant component). Use entity_key from each + group together with the publish_history_entries/ endpoint to fetch the + individual draft change entries for that group. + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + history = api.get_library_container_publish_history(container_key, request=request) + return Response(self.serializer_class(history, 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 f01b57286695..ca48d178fb1d 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -207,9 +207,10 @@ class LibraryPublishHistoryGroupSerializer(serializers.Serializer): 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) + block_type = serializers.CharField(read_only=True, allow_null=True) contributors = LibraryHistoryContributorSerializer(many=True, read_only=True) contributors_count = serializers.IntegerField(read_only=True) + entity_key = serializers.CharField(read_only=True) def get_published_by(self, obj) -> str | None: return obj.published_by.username if obj.published_by else None diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 9fdc334b1e0e..c3f70de7a46d 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -41,7 +41,7 @@ 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_PUBLISH_HISTORY_ENTRIES = URL_LIB_DETAIL + 'publish_history_entries/' URL_LIB_BLOCK_CREATION_ENTRY = URL_LIB_BLOCK + 'creation_entry/' 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 @@ -340,14 +340,18 @@ 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, - ) + def _get_publish_history_entries(self, lib_key, entity_key, publish_log_uuid, expect_response=200): + """ Get the draft change entries for a specific publish event (component or container) """ + url = URL_LIB_PUBLISH_HISTORY_ENTRIES.format(lib_key=lib_key) + url += f'?entity_key={entity_key}&publish_log_uuid={publish_log_uuid}' return self._api('get', url, 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 for a component block """ + parsed_key = UsageKey.from_string(block_key) if isinstance(block_key, str) else block_key + lib_key = parsed_key.lib_key + return self._get_publish_history_entries(lib_key, block_key, publish_log_uuid, expect_response) + def _get_block_creation_entry(self, block_key, expect_response=200): """ Get the creation entry for a block (the moment it was first saved) """ return self._api('get', URL_LIB_BLOCK_CREATION_ENTRY.format(block_key=block_key), None, expect_response) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 3f891584f2cb..7b4bd92aa6e7 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -1029,8 +1029,10 @@ def test_container_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. """ - unit = self._create_container(self.lib["id"], "unit", display_name="History Unit Edits", slug=None) - self._publish_container(unit["id"]) + with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + unit = self._create_container(self.lib["id"], "unit", display_name="History Unit Edits", slug=None) + with freeze_time(datetime(2026, 2, 1, tzinfo=timezone.utc)): + self._publish_container(unit["id"]) edit1_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc) with freeze_time(edit1_time): @@ -1054,10 +1056,12 @@ def test_container_draft_history_includes_descendant_components(self): The history of a container includes entries from its descendant components, merged and sorted newest-first. """ - unit = self._create_container(self.lib["id"], "unit", display_name="History Unit Children", slug=None) - block = self._add_block_to_library(self.lib["id"], "problem", "hist-prob", can_stand_alone=False) - self._add_container_children(unit["id"], children_ids=[block["id"]]) - self._publish_container(unit["id"]) + with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + unit = self._create_container(self.lib["id"], "unit", display_name="History Unit Children", slug=None) + block = self._add_block_to_library(self.lib["id"], "problem", "hist-prob", can_stand_alone=False) + self._add_container_children(unit["id"], children_ids=[block["id"]]) + with freeze_time(datetime(2026, 2, 1, tzinfo=timezone.utc)): + self._publish_container(unit["id"]) container_edit_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc) with freeze_time(container_edit_time): 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 45920b294845..d509dcfb952f 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -908,11 +908,13 @@ 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"] + with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + 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) + with freeze_time(datetime(2026, 2, 1, tzinfo=timezone.utc)): + self._publish_library_block(block_key) edit1_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc) with freeze_time(edit1_time): @@ -1101,7 +1103,7 @@ def test_publish_history_entries(self): def test_publish_history_entries_unknown_uuid(self): """ - Requesting entries for a publish_log_uuid unrelated to this component returns 404. + Requesting entries for a publish_log_uuid unrelated to this component returns an empty list. """ lib = self._create_library(slug="hist-baduid", title="History Bad UUID") block = self._add_block_to_library(lib["id"], "problem", "prob1") @@ -1111,7 +1113,8 @@ def test_publish_history_entries_unknown_uuid(self): self._publish_library_block(block_key) fake_uuid = str(uuid.uuid4()) - self._get_block_publish_history_entries(block_key, fake_uuid, expect_response=404) + entries = self._get_block_publish_history_entries(block_key, fake_uuid, expect_response=200) + assert entries == [] def test_publish_history_nonexistent_block(self): """ diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index d3899950d977..c490f136d5a0 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -53,6 +53,8 @@ path('team/group//', libraries.LibraryTeamGroupView.as_view()), # Import blocks into this library. path('import_blocks/', include(import_blocks_router.urls)), + # Get draft change entries for a specific publish event (component or container) + path('publish_history_entries/', blocks.LibraryPublishHistoryEntriesView.as_view()), # Paste contents of clipboard into library path('paste_clipboard/', libraries.LibraryPasteClipboardView.as_view()), # Start a backup task for this library @@ -81,11 +83,6 @@ 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() - ), # Get the creation entry for this block path('creation_entry/', blocks.LibraryComponentCreationEntryView.as_view()), # Future: discard changes for just this one block @@ -107,6 +104,8 @@ path('copy/', containers.LibraryContainerCopyView.as_view()), # Get the draft change history for this container path('draft_history/', containers.LibraryContainerDraftHistoryView.as_view()), + # Get the publish history for this container (list of publish events) + path('publish_history/', containers.LibraryContainerPublishHistoryView.as_view()), ])), re_path(r'^lti/1.3/', include([ path('login/', libraries.LtiToolLoginView.as_view(), name='lti-login'), From 1596aae76113d217dfe72683d9fca91ae1ba9d1b Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Sun, 19 Apr 2026 18:23:43 -0500 Subject: [PATCH 08/31] feat: add Post-Verawood publish history with direct_published_entities and scope_entity_key [FC-0123] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigns publish history groups to support the `direct` field on PublishLogRecord (added in Verawood). Key changes: - Replace `entity_key/title/block_type` in `LibraryPublishHistoryGroup` with `direct_published_entities: list[DirectPublishedEntity]`, each carrying `entity_key`, `title`, and `entity_type` (works for both components and containers). - Add `scope_entity_key: str | None` to each group so the frontend always knows which key to pass to the entries endpoint without needing era awareness (`group.scope_entity_key ?? currentContainerKey`). - Pre-Verawood (direct=None): one group per entity × publish event; `scope_entity_key` = that entity's key. - Post-Verawood (direct!=None): one merged group per PublishLog; `scope_entity_key` = null (frontend uses current container). - Rename `block_type` → `item_type` (non-nullable) in `LibraryHistoryEntry`; populate it for containers via `container_type.type_code` (e.g. "unit", "section"). - Rename entries query param `entity_key` → `scope_entity_key`. - Add Pre-Verawood and Post-Verawood integration tests. --- .../content_libraries/api/block_metadata.py | 82 +++++- .../content_libraries/api/blocks.py | 55 +++- .../content_libraries/api/containers.py | 258 +++++++++++++----- .../content_libraries/rest_api/blocks.py | 21 +- .../content_libraries/rest_api/serializers.py | 16 +- .../content_libraries/tests/base.py | 17 +- .../tests/test_content_libraries.py | 244 ++++++++++++++++- 7 files changed, 582 insertions(+), 111 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index a9f81652eb67..1324781456e7 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -8,7 +8,8 @@ from django.utils.translation import gettext as _ from django.contrib.auth import get_user_model -from opaque_keys.edx.locator import LibraryUsageLocatorV2 +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_content.models_api import Component, PublishLogRecord from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user from .libraries import library_component_usage_key, PublishableItem @@ -77,7 +78,7 @@ class LibraryHistoryEntry: changed_by: LibraryHistoryContributor | None changed_at: datetime title: str # title at time of change - block_type: str | None + item_type: str action: str # "edited" | "renamed" @@ -97,23 +98,47 @@ def from_user(cls, user, request=None) -> 'LibraryHistoryContributor': ) +@dataclass(frozen=True) +class DirectPublishedEntity: + """ + Represents one entity the user directly requested to publish (direct=True). + Each entry carries its own title and entity_type so the frontend can display + the correct label for each directly published item. + + Pre-Verawood groups have exactly one entry (approximated from available data). + Post-Verawood groups have one entry per direct=True record in the PublishLog. + """ + entity_key: str # str(usage_key) for components, str(container_key) for containers + title: str # title of the entity at time of publish + entity_type: str # e.g. "html", "problem" for components; "unit", "section" for containers + + @dataclass(frozen=True) class LibraryPublishHistoryGroup: """ Summary of a publish event for a library item. - Each instance represents one PublishLogRecord for the item, and - includes the set of contributors who authored draft changes between the - previous publish and this one. + Each instance represents one or more PublishLogRecords, and includes the + set of contributors who authored draft changes between the previous publish + and this one. + + Pre-Verawood (direct=None): one group per entity × publish event. + Post-Verawood (direct!=None): one group per unique PublishLog. """ publish_log_uuid: str published_by: object # AUTH_USER_MODEL instance or None published_at: datetime - title: str # title at time of publish - block_type: str | None contributors: list[LibraryHistoryContributor] # distinct authors of versions in this group contributors_count: int - entity_key: str # str(usage_key) for components, str(container_key) for containers + # Replaces entity_key, title, block_type. Each element is one entity the + # user directly requested to publish. Pre-Verawood: single approximated entry. + # Post-Verawood: one entry per direct=True record in the PublishLog. + direct_published_entities: list[DirectPublishedEntity] + # Key to pass as scope_entity_key when fetching entries for this group. + # Pre-Verawood: the specific entity key for this group (container or usage key). + # Post-Verawood container groups: None — frontend must use currentContainerKey. + # Component history (all eras): str(usage_key). + scope_entity_key: str | None @dataclass(frozen=True) @@ -159,3 +184,44 @@ def resolve_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 direct_published_entity_from_record( + record: PublishLogRecord, + lib_key: LibraryLocatorV2, +) -> DirectPublishedEntity: + """ + Build a DirectPublishedEntity from a PublishLogRecord. + + lib_key is used only to construct locator strings — entity_key is always + derived from record.entity itself, never from an external container key. + + Callers must ensure the record is fetched with: + select_related( + 'entity__component__component_type', + 'entity__container__container_type', + 'new_version', + ) + """ + # Import here to avoid circular imports (container_metadata imports block_metadata). + from .container_metadata import library_container_locator # noqa: PLC0415 + + title = record.new_version.title if record.new_version else "" + try: + component = record.entity.component + return DirectPublishedEntity( + entity_key=str(LibraryUsageLocatorV2( # type: ignore[abstract] + lib_key=lib_key, + block_type=component.component_type.name, + usage_id=component.local_key, + )), + title=title, + entity_type=component.component_type.name, + ) + except Component.DoesNotExist: + container = record.entity.container + return DirectPublishedEntity( + entity_key=str(library_container_locator(lib_key, container)), + title=title, + entity_type=container.container_type.type_code, + ) diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 200deecfb321..99eba29d7803 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -24,7 +24,7 @@ from opaque_keys.edx.keys import LearningContextKey, UsageKeyV2 from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content import api as content_api -from openedx_content.models_api import Collection, Component, ComponentVersion, Container, LearningPackage, MediaType +from openedx_content.models_api import Collection, Component, ComponentVersion, Container, LearningPackage, MediaType, PublishLogRecord from openedx_events.content_authoring.data import ( ContentObjectChangedData, LibraryBlockData, @@ -60,6 +60,8 @@ LibraryBlockAlreadyExists, ) from .block_metadata import ( + DirectPublishedEntity, + direct_published_entity_from_record, LibraryHistoryEntry, LibraryPublishHistoryGroup, LibraryXBlockMetadata, @@ -228,7 +230,7 @@ def get_library_component_draft_history( 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, + item_type=record.entity.component.component_type.name, action=resolve_change_action(record.old_version, record.new_version), )) return entries @@ -246,13 +248,14 @@ def get_library_component_publish_history( - the distinct set of contributors: users who authored draft changes between the previous publish and this one (via DraftChangeLogRecord version bounds) + direct_published_entities per era: + - Pre-Verawood (direct=None): single entry for the component itself. + - Post-Verawood, direct=True: single entry for the component (directly published). + - Post-Verawood, direct=False: all direct=True records from the same PublishLog + (e.g. a parent container that was directly published). + 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) @@ -274,18 +277,44 @@ def get_library_component_publish_history( 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] + if pub_record.direct is None or pub_record.direct is True: + # Pre-Verawood or component was directly published: single entry for itself. + direct_published_entities = [DirectPublishedEntity( + entity_key=str(usage_key), + title=pub_record.new_version.title if pub_record.new_version else "", + entity_type=pub_record.entity.component.component_type.name, + )] + else: + # Post-Verawood, direct=False: component published as a dependency. + # Find all direct=True records in the same PublishLog. + direct_records = list( + pub_record.publish_log.records + .filter(direct=True) + .select_related( + 'entity__component__component_type', + 'entity__container__container_type', + 'new_version', + ) + ) + direct_published_entities = [ + direct_published_entity_from_record(r, usage_key.lib_key) + for r in direct_records + ] or [DirectPublishedEntity( + entity_key=str(usage_key), + title=pub_record.new_version.title if pub_record.new_version else "", + entity_type=pub_record.entity.component.component_type.name, + )] + groups.append(LibraryPublishHistoryGroup( 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, - 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), - entity_key=str(usage_key), + direct_published_entities=direct_published_entities, + scope_entity_key=str(usage_key), )) return groups @@ -322,7 +351,7 @@ def get_library_component_publish_history_entries( 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, + item_type=record.entity.component.component_type.name, action=resolve_change_action(record.old_version, record.new_version), )) return entries @@ -360,7 +389,7 @@ def get_library_component_creation_entry( changed_by=changed_by_list[0], changed_at=first_version.created, title=first_version.title, - block_type=component.component_type.name, + item_type=component.component_type.name, action="created", ) diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 328d51dccb23..bb051d8b1285 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -14,7 +14,7 @@ from django.utils.text import slugify from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content import api as content_api -from openedx_content.models_api import Container, Unit, Component +from openedx_content.models_api import Container, Unit, Component, PublishLogRecord from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData, LibraryContainerData from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, @@ -32,6 +32,8 @@ LibraryXBlockMetadata, LibraryHistoryEntry, LibraryPublishHistoryGroup, + DirectPublishedEntity, + direct_published_entity_from_record, resolve_contributors, resolve_change_action, ) @@ -551,17 +553,14 @@ def get_library_container_draft_history( # (e.g. for delete records where new_version is None). version = record.new_version if record.new_version is not None else record.old_version try: - block_type = record.entity.component.component_type.name + item_type = record.entity.component.component_type.name except Component.DoesNotExist: - # The entity is a Container, which has no component_type. - # TODO: update openedx-core to expose container_type so we can - # populate block_type for containers as well. - block_type = None + item_type = record.entity.container.container_type.type_code entries.append(LibraryHistoryEntry( changed_by=changed_by, changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", - block_type=block_type, + item_type=item_type, action=resolve_change_action(record.old_version, record.new_version), )) @@ -582,97 +581,210 @@ def get_library_container_publish_history( """ [ 🛑 UNSTABLE ] Return the publish history of a container as a list of groups. - Each group represents one publish event for ONE entity (the container itself - or a descendant component). When the container and components are published - together, each entity produces its own independent group — the component - group is identical to what get_library_component_publish_history returns - for that component. + Pre-Verawood records (direct=None): one group per entity × publish event + (same PublishLog may produce multiple groups — one per entity in scope). - Each group includes entity_key: str(container_key) for the container itself, - or str(usage_key) for a descendant component. The frontend uses entity_key - to call the correct entries endpoint when expanding a group. + Post-Verawood records (direct!=None): one group per unique PublishLog that + touched the container or any descendant. Contributors are accumulated across + all entities in that PublishLog within scope. direct_published_entities lists + the entities the user directly clicked "Publish" on. Groups are ordered most-recent-first. Returns [] if nothing has been published. """ container = get_container_from_key(container_key) component_entity_ids = content_api.get_descendant_component_entity_ids(container) + all_entity_ids = [container.pk] + component_entity_ids - groups = [] - for entity_id in [container.pk] + component_entity_ids: + # Collect all records grouped by publish_log_uuid. + publish_log_groups: dict[str, list[tuple[int, PublishLogRecord]]] = {} + for entity_id in all_entity_ids: for pub_record in content_api.get_entity_publish_history(entity_id): - old_version_num = pub_record.old_version.version_num if pub_record.old_version else 0 - new_version_num = pub_record.new_version.version_num if pub_record.new_version else None - - raw_contributors = list(content_api.get_entity_version_contributors( - entity_id, - 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] + uuid = str(pub_record.publish_log.uuid) + publish_log_groups.setdefault(uuid, []).append((entity_id, pub_record)) - try: - component = pub_record.entity.component - block_type = component.component_type.name - entity_key = str(LibraryUsageLocatorV2( # type: ignore[abstract] - lib_key=container_key.lib_key, - block_type=component.component_type.name, - usage_id=component.local_key, - )) - except Component.DoesNotExist: - block_type = None - entity_key = str(container_key) - - groups.append(LibraryPublishHistoryGroup( - 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, - title=pub_record.new_version.title if pub_record.new_version else "", - block_type=block_type, - contributors_count=len(contributors), - entity_key=entity_key, - )) + groups = [] + for uuid, entity_records in publish_log_groups.items(): + # Era is uniform across all records in one PublishLog. + is_post_verawood = entity_records[0][1].direct is not None + + if is_post_verawood: + # ONE merged group for this entire PublishLog. + groups.append( + _build_post_verawood_container_group( + uuid, entity_records, container_key, request + ) + ) + else: + # Pre-Verawood: one group per entity-record pair (separated). + for entity_id, pub_record in entity_records: + groups.append( + _build_pre_verawood_container_group( + pub_record, entity_id, container_key, request + ) + ) groups.sort(key=lambda g: g.published_at, reverse=True) return groups -def get_library_container_publish_history_entries( +def _build_post_verawood_container_group( + uuid: str, + entity_records: list[tuple[int, PublishLogRecord]], container_key: LibraryContainerLocator, + request, +) -> LibraryPublishHistoryGroup: + """ + Build one merged LibraryPublishHistoryGroup for a Post-Verawood PublishLog. + + Queries the full PublishLog for direct=True records (covers both in-scope + and out-of-scope cases, e.g. a shared component published from a sibling). + Contributors are accumulated across all in-scope entity records. + """ + publish_log = entity_records[0][1].publish_log + direct_records = list( + publish_log.records + .filter(direct=True) + .select_related( + 'entity__component__component_type', + 'entity__container__container_type', + 'new_version', + ) + ) + direct_published_entities = [ + direct_published_entity_from_record(r, container_key.lib_key) + for r in direct_records + ] + + seen_usernames: set[str] = set() + all_contributors = [] + for entity_id, pub_record in entity_records: + old_version_num = pub_record.old_version.version_num if pub_record.old_version else 0 + new_version_num = pub_record.new_version.version_num if pub_record.new_version else None + raw = list(content_api.get_entity_version_contributors( + entity_id, + old_version_num=old_version_num, + new_version_num=new_version_num, + )) + for contributor in resolve_contributors(raw, request): + if contributor is not None and contributor.username not in seen_usernames: + seen_usernames.add(contributor.username) + all_contributors.append(contributor) + + return LibraryPublishHistoryGroup( + publish_log_uuid=uuid, + published_by=publish_log.published_by, + published_at=publish_log.published_at, + contributors=all_contributors, + contributors_count=len(all_contributors), + direct_published_entities=direct_published_entities, + scope_entity_key=None, + ) + + +def _build_pre_verawood_container_group( + pub_record: PublishLogRecord, + entity_id: int, + container_key: LibraryContainerLocator, + request, +) -> LibraryPublishHistoryGroup: + """ + Build one LibraryPublishHistoryGroup for a Pre-Verawood record. + + One group per entity × publish event (separated). entity_key is approximated: + str(container_key) for the container itself, str(usage_key) for a component. + """ + old_version_num = pub_record.old_version.version_num if pub_record.old_version else 0 + new_version_num = pub_record.new_version.version_num if pub_record.new_version else None + raw = list(content_api.get_entity_version_contributors( + entity_id, + old_version_num=old_version_num, + new_version_num=new_version_num, + )) + contributors = [c for c in resolve_contributors(raw, request) if c is not None] + + entity = direct_published_entity_from_record(pub_record, container_key.lib_key) + return LibraryPublishHistoryGroup( + 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), + # Pre-Verawood: single approximated entry built from the record itself. + direct_published_entities=[entity], + scope_entity_key=entity.entity_key, + ) + + +def get_library_container_publish_history_entries( + scope_entity_key: LibraryContainerLocator, publish_log_uuid: str, request=None, ) -> list[LibraryHistoryEntry]: """ - [ 🛑 UNSTABLE ] Return the individual draft change entries for the container - entity in a specific publish event. + [ 🛑 UNSTABLE ] Return the individual draft change entries for all entities + in scope that participated in a specific publish event. + + scope_entity_key identifies the container being viewed — it defines which + entities' entries to return (the container + its descendants). This may differ + from the direct_published_entities in the publish group (e.g. a parent Section + was directly published, but the scope here is a child Unit). - Only returns entries for the container itself (not descendant components). - For component groups, use get_library_component_publish_history_entries. - Returns [] if the container was not part of this publish event. + Post-Verawood (direct!=None): returns entries for all entities in scope that + participated in the PublishLog. + + Pre-Verawood (direct=None): returns entries only for the container itself + (old behavior — one group per entity, scope == directly published entity). + + Returns [] if no entities in scope participated in this publish event. """ - container = get_container_from_key(container_key) + container = get_container_from_key(scope_entity_key) + component_entity_ids = content_api.get_descendant_component_entity_ids(container) + scope_entity_ids = {container.pk} | set(component_entity_ids) - try: - records = list(content_api.get_entity_publish_history_entries( - container.pk, publish_log_uuid - )) - except ObjectDoesNotExist: - return [] + publish_log_records = PublishLogRecord.objects.filter(publish_log__uuid=publish_log_uuid) + is_post_verawood = publish_log_records.filter(direct__isnull=False).exists() - changed_by_list = resolve_contributors( - (record.draft_change_log.changed_by for record in records), request - ) + if is_post_verawood: + # Return entries for all entities in scope that participated in this PublishLog. + relevant_entity_ids = ( + set(publish_log_records.values_list('entity_id', flat=True)) & scope_entity_ids + ) + else: + # Pre-Verawood: scope_entity_key is the directly published entity. + # Return entries only for the container itself (old behavior). + relevant_entity_ids = {container.pk} & set( + publish_log_records.values_list('entity_id', flat=True) + ) + + if not relevant_entity_ids: + return [] 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(LibraryHistoryEntry( - changed_by=changed_by, - changed_at=record.draft_change_log.changed_at, - title=version.title if version is not None else "", - block_type=None, - action=resolve_change_action(record.old_version, record.new_version), - )) + for entity_id in relevant_entity_ids: + try: + records = list( + content_api.get_entity_publish_history_entries(entity_id, publish_log_uuid) + .select_related('entity__container__container_type') + ) + except ObjectDoesNotExist: + continue + + changed_by_list = resolve_contributors( + (record.draft_change_log.changed_by for record in records), request + ) + 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 + try: + item_type = record.entity.component.component_type.name + except Component.DoesNotExist: + item_type = record.entity.container.container_type.type_code + entries.append(LibraryHistoryEntry( + changed_by=changed_by, + changed_at=record.draft_change_log.changed_at, + title=version.title if version is not None else "", + item_type=item_type, + action=resolve_change_action(record.old_version, record.new_version), + )) entries.sort(key=lambda entry: entry.changed_at, reverse=True) 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 0406fcdc3d3a..a7c07aad4527 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -186,7 +186,12 @@ class LibraryPublishHistoryEntriesView(APIView): Unified view to get individual draft change entries for a specific publish event. Accepts any library entity key (component usage_key or container key) via the - entity_key query parameter and routes to the appropriate API function. + scope_entity_key query parameter and routes to the appropriate API function. + + For containers, scope_entity_key identifies the container being viewed — not + necessarily the entity that was directly published. In Post-Verawood a parent + container may have been directly published, but scope_entity_key is the child + Unit the user is currently browsing. """ serializer_class = serializers.LibraryHistoryEntrySerializer @@ -196,18 +201,18 @@ def get(self, request, lib_key_str): Get the draft change entries for a specific publish event, ordered most-recent-first. Query parameters: - - entity_key: the usage_key (component) or container_key (container) + - scope_entity_key: the usage_key (component) or container_key (scope container) - publish_log_uuid: UUID of the publish event """ lib_key = LibraryLocatorV2.from_string(lib_key_str) api.require_permission_for_library_key(lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) - entity_key_str = request.query_params.get("entity_key", "") + scope_entity_key_str = request.query_params.get("scope_entity_key", "") publish_log_uuid = request.query_params.get("publish_log_uuid", "") - if not entity_key_str or not publish_log_uuid: - return Response({"error": "entity_key and publish_log_uuid are required."}, status=400) + if not scope_entity_key_str or not publish_log_uuid: + return Response({"error": "scope_entity_key and publish_log_uuid are required."}, status=400) try: - usage_key = LibraryUsageLocatorV2.from_string(entity_key_str) + usage_key = LibraryUsageLocatorV2.from_string(scope_entity_key_str) entries = api.get_library_component_publish_history_entries( usage_key, publish_log_uuid, request=request ) @@ -215,12 +220,12 @@ def get(self, request, lib_key_str): entries = [] except (InvalidKeyError, AttributeError): try: - container_key = LibraryContainerLocator.from_string(entity_key_str) + container_key = LibraryContainerLocator.from_string(scope_entity_key_str) entries = api.get_library_container_publish_history_entries( container_key, publish_log_uuid, request=request ) except (InvalidKeyError, AttributeError): - return Response({"error": f"Invalid entity_key: {entity_key_str!r}"}, status=400) + return Response({"error": f"Invalid scope_entity_key: {scope_entity_key_str!r}"}, status=400) 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 ca48d178fb1d..7170456f72e7 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -195,22 +195,30 @@ class LibraryHistoryEntrySerializer(serializers.Serializer): changed_by = LibraryHistoryContributorSerializer(allow_null=True, read_only=True) changed_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) title = serializers.CharField(read_only=True) - block_type = serializers.CharField(read_only=True) + item_type = serializers.CharField(read_only=True) action = serializers.CharField(read_only=True) +class DirectPublishedEntitySerializer(serializers.Serializer): + """ + Serializer for one entity the user directly requested to publish (direct=True). + """ + entity_key = serializers.CharField(read_only=True) + title = serializers.CharField(read_only=True) + entity_type = serializers.CharField(read_only=True) + + class LibraryPublishHistoryGroupSerializer(serializers.Serializer): """ Serializer for a publish event summary in the publish history of a library item. """ 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, allow_null=True) contributors = LibraryHistoryContributorSerializer(many=True, read_only=True) contributors_count = serializers.IntegerField(read_only=True) - entity_key = serializers.CharField(read_only=True) + direct_published_entities = DirectPublishedEntitySerializer(many=True, read_only=True) + scope_entity_key = serializers.CharField(read_only=True, allow_null=True) def get_published_by(self, obj) -> str | None: return obj.published_by.username if obj.published_by else None diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index c3f70de7a46d..387633c9009e 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -55,6 +55,7 @@ URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to the specified container + children URL_LIB_CONTAINER_COPY = URL_LIB_CONTAINER + 'copy/' # Copy the specified container to the clipboard URL_LIB_CONTAINER_DRAFT_HISTORY = URL_LIB_CONTAINER + 'draft_history/' # Draft change history for a container +URL_LIB_CONTAINER_PUBLISH_HISTORY = URL_LIB_CONTAINER + 'publish_history/' # Publish event history for a container URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' # Get a collection in this library URL_LIB_COLLECTION_ITEMS = URL_LIB_COLLECTION + 'items/' # Get a collection in this library @@ -340,10 +341,16 @@ 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_publish_history_entries(self, lib_key, entity_key, publish_log_uuid, expect_response=200): + def _get_container_publish_history(self, container_key, expect_response=200): + """ Get the publish event history for a container """ + return self._api( + 'get', URL_LIB_CONTAINER_PUBLISH_HISTORY.format(container_key=container_key), None, expect_response + ) + + def _get_publish_history_entries(self, lib_key, scope_entity_key, publish_log_uuid, expect_response=200): """ Get the draft change entries for a specific publish event (component or container) """ url = URL_LIB_PUBLISH_HISTORY_ENTRIES.format(lib_key=lib_key) - url += f'?entity_key={entity_key}&publish_log_uuid={publish_log_uuid}' + url += f'?scope_entity_key={scope_entity_key}&publish_log_uuid={publish_log_uuid}' return self._api('get', url, None, expect_response) def _get_block_publish_history_entries(self, block_key, publish_log_uuid, expect_response=200): @@ -352,6 +359,12 @@ def _get_block_publish_history_entries(self, block_key, publish_log_uuid, expect lib_key = parsed_key.lib_key return self._get_publish_history_entries(lib_key, block_key, publish_log_uuid, expect_response) + def _get_container_publish_history_entries(self, container_key, publish_log_uuid, expect_response=200): + """ Get the draft change entries for a specific publish event for a container """ + parsed_key = ContainerKey.from_string(container_key) if isinstance(container_key, str) else container_key + lib_key = parsed_key.lib_key + return self._get_publish_history_entries(lib_key, container_key, publish_log_uuid, expect_response) + def _get_block_creation_entry(self, block_key, expect_response=200): """ Get the creation entry for a block (the moment it was first saved) """ return self._api('get', URL_LIB_BLOCK_CREATION_ENTRY.format(block_key=block_key), None, expect_response) 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 d509dcfb952f..2edca3c5ffae 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -1011,8 +1011,9 @@ def test_publish_history_empty_before_first_publish(self): def test_publish_history_after_single_publish(self): """ - After one publish the history contains exactly one group with the - correct publisher, timestamp, and contributor. + Post-Verawood: After one direct component publish (direct=True) the history + contains exactly one group with the correct publisher, timestamp, contributor, + and a single entry in direct_published_entities for the component itself. """ lib = self._create_library(slug="hist-single", title="History Single") block = self._add_block_to_library(lib["id"], "problem", "prob1") @@ -1030,6 +1031,11 @@ def test_publish_history_after_single_publish(self): assert isinstance(group["publish_log_uuid"], str) assert group["contributors_count"] >= 1 assert any(c["username"] == self.user.username for c in group["contributors"]) + # Post-Verawood: component was directly published → single entry for itself + assert len(group["direct_published_entities"]) == 1 + entity = group["direct_published_entities"][0] + assert entity["entity_key"] == block_key + assert entity["entity_type"] == "problem" def test_publish_history_multiple_publishes(self): """ @@ -1137,6 +1143,238 @@ def test_publish_history_permissions(self): with self.as_user(unauthorized): self._get_block_publish_history(block_key, expect_response=403) + # --- Post-Verawood publish history tests --- + + def test_post_verawood_component_published_directly(self): + """ + Post-Verawood, direct=True: when a component is published directly, + direct_published_entities has a single entry for the component itself. + The component's own history and the container's history both show the + component as the directly published entity. + """ + lib = self._create_library(slug="pv-comp-direct", title="PV Comp Direct") + unit = self._create_container(lib["id"], "unit", "u1", "Unit 1") + unit_key = unit["id"] + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._add_container_children(unit_key, [block_key]) + + # Publish the component directly (not the unit) + self._publish_library_block(block_key) + + # Component history: direct_published_entities = [component] + comp_history = self._get_block_publish_history(block_key) + assert len(comp_history) == 1 + entities = comp_history[0]["direct_published_entities"] + assert len(entities) == 1 + assert entities[0]["entity_key"] == block_key + assert entities[0]["entity_type"] == "problem" + # scope_entity_key is always the component itself for component history + assert comp_history[0]["scope_entity_key"] == block_key + + # Container history: same publish log → same direct_published_entities + unit_history = self._get_container_publish_history(unit_key) + assert len(unit_history) == 1 + entities = unit_history[0]["direct_published_entities"] + assert len(entities) == 1 + assert entities[0]["entity_key"] == block_key + assert entities[0]["entity_type"] == "problem" + # Post-Verawood container group: scope_entity_key is null (frontend uses current container) + assert unit_history[0]["scope_entity_key"] is None + + def test_post_verawood_unit_published_directly(self): + """ + Post-Verawood, direct=True on the Unit: when a Unit is published directly, + the Unit's history shows the unit as directly published. The child component's + history shows the unit as the directly published entity (direct=False on component). + """ + lib = self._create_library(slug="pv-unit-direct", title="PV Unit Direct") + unit = self._create_container(lib["id"], "unit", "u1", "Unit 1") + unit_key = unit["id"] + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._add_container_children(unit_key, [block_key]) + + # Publish the unit directly (component is published as a dependency) + self._publish_container(unit_key) + + # Container history: 1 group, unit is direct + unit_history = self._get_container_publish_history(unit_key) + assert len(unit_history) == 1 + entities = unit_history[0]["direct_published_entities"] + assert len(entities) == 1 + assert entities[0]["entity_key"] == unit_key + assert entities[0]["entity_type"] == "unit" + assert unit_history[0]["scope_entity_key"] is None + + # Component history: 1 group, unit is the directly published entity + comp_history = self._get_block_publish_history(block_key) + assert len(comp_history) == 1 + entities = comp_history[0]["direct_published_entities"] + assert len(entities) == 1 + assert entities[0]["entity_key"] == unit_key + assert entities[0]["entity_type"] == "unit" + assert comp_history[0]["scope_entity_key"] == block_key + + def test_post_verawood_container_history_merges_same_publish_log(self): + """ + Post-Verawood: when the Unit and a Component are both touched in the same + PublishLog, the container history returns ONE merged group (not two separate + groups as in Pre-Verawood). + """ + lib = self._create_library(slug="pv-merged", title="PV Merged") + unit = self._create_container(lib["id"], "unit", "u1", "Unit 1") + unit_key = unit["id"] + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._add_container_children(unit_key, [block_key]) + + self._publish_container(unit_key) + + unit_history = self._get_container_publish_history(unit_key) + # Post-Verawood: ONE merged group for the entire PublishLog + assert len(unit_history) == 1 + + def test_post_verawood_container_history_entries_scope(self): + """ + Post-Verawood: the entries endpoint for a container returns entries for all + entities in scope (container + descendants) that participated in the PublishLog, + not just the container itself. + """ + lib = self._create_library(slug="pv-entries-scope", title="PV Entries Scope") + unit = self._create_container(lib["id"], "unit", "u1", "Unit 1") + unit_key = unit["id"] + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._add_container_children(unit_key, [block_key]) + + self._publish_container(unit_key) + + unit_history = self._get_container_publish_history(unit_key) + assert len(unit_history) == 1 + publish_log_uuid = unit_history[0]["publish_log_uuid"] + + entries = self._get_container_publish_history_entries(unit_key, publish_log_uuid) + # Post-Verawood: entries for both the unit and the component + assert len(entries) >= 1 + item_types = {e["item_type"] for e in entries} + assert "unit" in item_types + assert "problem" in item_types + + def test_post_verawood_multiple_publishes_stay_separate(self): + """ + Post-Verawood: two separate publish events produce two separate groups, + ordered most-recent-first. + """ + lib = self._create_library(slug="pv-multi", title="PV Multi") + unit = self._create_container(lib["id"], "unit", "u1", "Unit 1") + unit_key = unit["id"] + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._add_container_children(unit_key, [block_key]) + + first_publish = datetime(2026, 1, 1, tzinfo=timezone.utc) + with freeze_time(first_publish): + self._publish_container(unit_key) + + self._set_library_block_olx(block_key, "

v2

") + + second_publish = datetime(2026, 2, 1, tzinfo=timezone.utc) + with freeze_time(second_publish): + self._publish_container(unit_key) + + unit_history = self._get_container_publish_history(unit_key) + assert len(unit_history) == 2 + assert unit_history[0]["published_at"] == second_publish.isoformat().replace("+00:00", "Z") + assert unit_history[1]["published_at"] == first_publish.isoformat().replace("+00:00", "Z") + + # --- Pre-Verawood publish history tests --- + # Pre-Verawood records have direct=None. We simulate them by publishing and + # then backfilling direct=None on the resulting PublishLogRecords, mirroring + # what the 0007_publishlogrecord_direct migration does for historical data. + + def test_pre_verawood_component_history_uses_component_as_entity(self): + """ + Pre-Verawood (direct=None): component history has one group per publish event. + direct_published_entities has a single approximated entry for the component itself. + """ + from openedx_content.models_api import PublishLogRecord + + lib = self._create_library(slug="prev-comp", title="PreV Comp") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._publish_library_block(block_key) + + # Simulate Pre-Verawood by backfilling direct=None + PublishLogRecord.objects.all().update(direct=None) + + history = self._get_block_publish_history(block_key) + assert len(history) == 1 + entities = history[0]["direct_published_entities"] + assert len(entities) == 1 + assert entities[0]["entity_key"] == block_key + assert entities[0]["entity_type"] == "problem" + assert history[0]["scope_entity_key"] == block_key + + def test_pre_verawood_container_history_produces_separate_groups(self): + """ + Pre-Verawood (direct=None): when a Unit and Component are published in the + same PublishLog, the container history produces SEPARATE groups — one per + entity (unlike Post-Verawood which merges into one group). + """ + from openedx_content.models_api import PublishLogRecord + + lib = self._create_library(slug="prev-separate", title="PreV Separate") + unit = self._create_container(lib["id"], "unit", "u1", "Unit 1") + unit_key = unit["id"] + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._add_container_children(unit_key, [block_key]) + self._publish_container(unit_key) + + # Simulate Pre-Verawood by backfilling direct=None + PublishLogRecord.objects.all().update(direct=None) + + unit_history = self._get_container_publish_history(unit_key) + # Pre-Verawood: one group per entity (unit + component = 2 groups) + assert len(unit_history) == 2 + # Each group's scope_entity_key matches its own entity_key + for group in unit_history: + assert group["scope_entity_key"] == group["direct_published_entities"][0]["entity_key"] + + def test_pre_verawood_container_history_entries_only_container_itself(self): + """ + Pre-Verawood (direct=None): the entries endpoint returns entries only for + the container itself, not for descendant components (old behavior preserved). + """ + from openedx_content.models_api import PublishLogRecord + + lib = self._create_library(slug="prev-entries", title="PreV Entries") + unit = self._create_container(lib["id"], "unit", "u1", "Unit 1") + unit_key = unit["id"] + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._add_container_children(unit_key, [block_key]) + self._publish_container(unit_key) + + # Simulate Pre-Verawood by backfilling direct=None + PublishLogRecord.objects.all().update(direct=None) + + unit_history = self._get_container_publish_history(unit_key) + # Find the group whose approximated entity_key is the unit itself + unit_group = next( + g for g in unit_history + if g["direct_published_entities"][0]["entity_key"] == unit_key + ) + publish_log_uuid = unit_group["publish_log_uuid"] + + entries = self._get_container_publish_history_entries(unit_key, publish_log_uuid) + # Pre-Verawood: entries only for the container itself + assert len(entries) >= 1 + # All entries should be for the unit, not the component + for entry in entries: + assert entry["item_type"] == "unit" + def test_creation_entry_returns_first_version(self): """ The creation entry corresponds to the first time the block was saved, @@ -1150,7 +1388,7 @@ def test_creation_entry_returns_first_version(self): assert entry is not None assert entry["action"] == "created" - assert entry["block_type"] == "problem" + assert entry["item_type"] == "problem" assert "changed_at" in entry assert "title" in entry assert "changed_by" in entry From b0df5d3b026d875a3c2b087edd7e5f03006ccae5 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Sun, 19 Apr 2026 18:46:11 -0500 Subject: [PATCH 09/31] fix: Broken lints --- openedx/core/djangoapps/content_libraries/api/blocks.py | 4 +++- openedx/core/djangoapps/content_libraries/api/containers.py | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 99eba29d7803..76e3b980fea2 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -24,7 +24,9 @@ from opaque_keys.edx.keys import LearningContextKey, UsageKeyV2 from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content import api as content_api -from openedx_content.models_api import Collection, Component, ComponentVersion, Container, LearningPackage, MediaType, PublishLogRecord +from openedx_content.models_api import ( + Collection, Component, ComponentVersion, Container, LearningPackage, MediaType, +) from openedx_events.content_authoring.data import ( ContentObjectChangedData, LibraryBlockData, diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index bb051d8b1285..fc2866d74254 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -32,7 +32,6 @@ LibraryXBlockMetadata, LibraryHistoryEntry, LibraryPublishHistoryGroup, - DirectPublishedEntity, direct_published_entity_from_record, resolve_contributors, resolve_change_action, From bfc0013be6e237a6dc1c90804588b63df4ef69b8 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Sun, 19 Apr 2026 20:30:46 -0500 Subject: [PATCH 10/31] feat: Function to get creation entry for containers --- .../content_libraries/api/containers.py | 32 ++++++++++++ .../content_libraries/rest_api/containers.py | 25 ++++++++++ .../content_libraries/tests/base.py | 7 +++ .../tests/test_content_libraries.py | 50 +++++++++++++++++++ .../core/djangoapps/content_libraries/urls.py | 2 + 5 files changed, 116 insertions(+) diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 0dc99441379d..c5116a7f5f7b 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -69,6 +69,7 @@ "get_library_container_draft_history", "get_library_container_publish_history", "get_library_container_publish_history_entries", + "get_library_container_creation_entry", ] log = logging.getLogger(__name__) @@ -787,3 +788,34 @@ def get_library_container_publish_history_entries( entries.sort(key=lambda entry: entry.changed_at, reverse=True) return entries + + +def get_library_container_creation_entry( + container_key: LibraryContainerLocator, + request=None, +) -> LibraryHistoryEntry | None: + """ + [ 🛑 UNSTABLE ] Return the creation entry for a library container. + + This is a single LibraryHistoryEntry representing the moment the container + was first created (version_num=1). Returns None if the container has no + versions yet. + """ + container = get_container_from_key(container_key) + first_version = ( + container.publishable_entity.versions + .filter(version_num=1) + .select_related("created_by") + .first() + ) + if first_version is None: + return None + + changed_by_list = resolve_contributors([first_version.created_by], request) + return LibraryHistoryEntry( + changed_by=changed_by_list[0], + changed_at=first_version.created, + title=first_version.title, + item_type=container.container_type.type_code, + action="created", + ) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index 008dcdb940e0..d610c5e0016c 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -16,6 +16,7 @@ from openedx_content import models_api as content_models from rest_framework.generics import GenericAPIView from rest_framework.response import Response +from rest_framework.views import APIView from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT from openedx.core.djangoapps.content_libraries import api, permissions @@ -485,3 +486,27 @@ def get(self, request: RestRequest, container_key: LibraryContainerLocator) -> R ) history = api.get_library_container_publish_history(container_key, request=request) return Response(self.serializer_class(history, many=True).data) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerCreationEntryView(APIView): + """ + View to get the creation entry for a library container. + """ + serializer_class = serializers.LibraryHistoryEntrySerializer + + @convert_exceptions + def get(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response: + """ + Get the creation entry for a library container (the moment it was first saved). + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + entry = api.get_library_container_creation_entry(container_key, request=request) + if entry is None: + return Response(None) + return Response(self.serializer_class(entry).data) diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 0eaba0deabc9..4137ee36d045 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -56,6 +56,7 @@ URL_LIB_CONTAINER_COPY = URL_LIB_CONTAINER + 'copy/' # Copy the specified container to the clipboard URL_LIB_CONTAINER_DRAFT_HISTORY = URL_LIB_CONTAINER + 'draft_history/' # Draft change history for a container URL_LIB_CONTAINER_PUBLISH_HISTORY = URL_LIB_CONTAINER + 'publish_history/' # Publish event history for a container +URL_LIB_CONTAINER_CREATION_ENTRY = URL_LIB_CONTAINER + 'creation_entry/' # Creation entry for a container URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' # Get a collection in this library URL_LIB_COLLECTION_ITEMS = URL_LIB_COLLECTION + 'items/' # Get a collection in this library @@ -365,6 +366,12 @@ def _get_block_creation_entry(self, block_key, expect_response=200): """ Get the creation entry for a block (the moment it was first saved) """ return self._api('get', URL_LIB_BLOCK_CREATION_ENTRY.format(block_key=block_key), None, expect_response) + def _get_container_creation_entry(self, container_key, expect_response=200): + """ Get the creation entry for a container (the moment it was first saved) """ + return self._api( + 'get', URL_LIB_CONTAINER_CREATION_ENTRY.format(container_key=container_key), 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 4b72bf932392..18f559538d07 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -1431,6 +1431,56 @@ def test_creation_entry_permissions(self): with self.as_user(unauthorized): self._get_block_creation_entry(block_key, expect_response=403) + def test_container_creation_entry_returns_first_version(self): + """ + The container creation entry corresponds to the first time the container was + saved, with action='created' and item_type matching the container type. + """ + lib = self._create_library(slug="ct-creation-basic", title="Container Creation Basic") + unit = self._create_container(lib["id"], "unit", slug="unit1", title="My Unit") + unit_key = unit["id"] + + entry = self._get_container_creation_entry(unit_key) + + assert entry is not None + assert entry["action"] == "created" + assert entry["item_type"] == "unit" + assert entry["title"] == "My Unit" + assert "changed_at" in entry + assert "changed_by" in entry + + def test_container_creation_entry_unchanged_after_edits(self): + """ + Subsequent edits and publishes do not affect the creation entry — it always + reflects the first saved version of the container. + """ + lib = self._create_library(slug="ct-creation-stable", title="Container Creation Stable") + unit = self._create_container(lib["id"], "unit", slug="unit1", title="Original Title") + unit_key = unit["id"] + + entry_before = self._get_container_creation_entry(unit_key) + + self._update_container(unit_key, display_name="Updated Title") + self._publish_container(unit_key) + + entry_after = self._get_container_creation_entry(unit_key) + + assert entry_after["changed_at"] == entry_before["changed_at"] + assert entry_after["action"] == "created" + assert entry_after["title"] == "Original Title" + + def test_container_creation_entry_permissions(self): + """ + A user without library access receives 403 for the container creation entry. + """ + lib = self._create_library(slug="ct-creation-auth", title="Container Creation Auth") + unit = self._create_container(lib["id"], "unit", slug="unit1", title="Auth Unit") + unit_key = unit["id"] + + unauthorized = UserFactory.create(username="noauth-ct-creation", password="edx") + with self.as_user(unauthorized): + self._get_container_creation_entry(unit_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 bfe7c825d841..8fabe7d96cca 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -97,6 +97,8 @@ path('draft_history/', containers.LibraryContainerDraftHistoryView.as_view()), # Get the publish history for this container (list of publish events) path('publish_history/', containers.LibraryContainerPublishHistoryView.as_view()), + # Get the creation entry for this container + path('creation_entry/', containers.LibraryContainerCreationEntryView.as_view()), ])), ])), path('library_assets/', include([ From 7cd6457198581e892a500b727fed3be217c7ef36 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 20 Apr 2026 18:53:52 -0500 Subject: [PATCH 11/31] temp: Update openedx-core version --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/kernel.in | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 0e5222f2cd6e..8d6f2e3a7379 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -838,7 +838,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/kernel.in # xblocks-contrib -openedx-core==0.39.2 +openedx-core @ git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 07df7c5080b3..af9f90539673 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1392,7 +1392,7 @@ openedx-calc==5.0.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # xblocks-contrib -openedx-core==0.39.2 +openedx-core @ git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 1992784fa48f..aaf0ae79d6f7 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1016,7 +1016,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core==0.39.2 +openedx-core @ git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log # via # -c requirements/constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 26448abf177d..b8a1b49b11e9 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -113,7 +113,7 @@ oauthlib # OAuth specification support for authentica olxcleaner openedx-atlas # CLI tool to manage translations openedx-calc # Library supporting mathematical calculations for Open edX -openedx-core # Open edX Core: Content, Tagging, and other foundational APIs +git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log#egg=openedx-core openedx-django-require openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 387483de3b7e..ba6f7e5206b5 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1063,7 +1063,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core==0.39.2 +openedx-core @ git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log # via # -c requirements/constraints.txt # -r requirements/edx/base.txt From cc877279a09da31215a99392aa2a4a66991b0488 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 20 Apr 2026 19:23:51 -0500 Subject: [PATCH 12/31] fix: Broken lints --- .../content_libraries/api/block_metadata.py | 4 +- .../content_libraries/api/blocks.py | 22 ++++----- .../content_libraries/api/containers.py | 6 +-- .../content_libraries/rest_api/containers.py | 2 +- .../tests/test_containers.py | 26 +++++------ .../tests/test_content_libraries.py | 46 +++++++++---------- 6 files changed, 53 insertions(+), 53 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index dbdaa2fcdac8..e187c0cf37f7 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -6,8 +6,8 @@ from dataclasses import dataclass from datetime import datetime -from django.utils.translation import gettext as _ # noqa: F401 from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _ # noqa: F401 from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content.models_api import Component, PublishLogRecord from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user @@ -94,7 +94,7 @@ class LibraryHistoryContributor: profile_image_urls: dict # {"full": str, "large": str, "medium": str, "small": str} @classmethod - def from_user(cls, user, request=None) -> 'LibraryHistoryContributor': + def from_user(cls, user, request=None) -> LibraryHistoryContributor: return cls( username=user.username, profile_image_urls=get_profile_image_urls_for_user(user, request), diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 76b094234bb9..40356c64e872 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -53,15 +53,6 @@ from .. import tasks from ..models import ContentLibrary -from .collections import library_collection_locator -from .container_metadata import container_subclass_for_olx_tag -from .exceptions import ( - BlockLimitReachedError, - ContentLibraryBlockNotFound, - IncompatibleTypesError, - InvalidNameError, - LibraryBlockAlreadyExists, -) from .block_metadata import ( DirectPublishedEntity, direct_published_entity_from_record, @@ -72,6 +63,15 @@ resolve_contributors, resolve_change_action, ) +from .collections import library_collection_locator +from .container_metadata import container_subclass_for_olx_tag +from .exceptions import ( + BlockLimitReachedError, + ContentLibraryBlockNotFound, + IncompatibleTypesError, + InvalidNameError, + LibraryBlockAlreadyExists, +) from .containers import ( create_container, get_container, @@ -227,7 +227,7 @@ def get_library_component_draft_history( ) entries = [] - for record, changed_by in zip(records, changed_by_list): + for record, changed_by in zip(records, changed_by_list, strict=False): version = record.new_version if record.new_version is not None else record.old_version entries.append(LibraryHistoryEntry( changed_by=changed_by, @@ -348,7 +348,7 @@ def get_library_component_publish_history_entries( ) entries = [] - for record, changed_by in zip(records, changed_by_list): + for record, changed_by in zip(records, changed_by_list, strict=False): version = record.new_version if record.new_version is not None else record.old_version entries.append(LibraryHistoryEntry( changed_by=changed_by, diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index c5116a7f5f7b..ba5077d496f1 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -9,8 +9,8 @@ from datetime import datetime, timezone from uuid import uuid4 -from django.db import transaction from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction from django.utils.text import slugify from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content import api as content_api @@ -548,7 +548,7 @@ def get_library_container_draft_history( ) entries = [] - for record, changed_by in zip(records, changed_by_list): + for record, changed_by in zip(records, changed_by_list, strict=False): # Use the new version when available; fall back to the old version # (e.g. for delete records where new_version is None). version = record.new_version if record.new_version is not None else record.old_version @@ -772,7 +772,7 @@ def get_library_container_publish_history_entries( changed_by_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request ) - for record, changed_by in zip(records, changed_by_list): + for record, changed_by in zip(records, changed_by_list, strict=False): version = record.new_version if record.new_version is not None else record.old_version try: item_type = record.entity.component.component_type.name diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index d610c5e0016c..431cfd09e9b0 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -16,8 +16,8 @@ from openedx_content import models_api as content_models from rest_framework.generics import GenericAPIView from rest_framework.response import Response -from rest_framework.views import APIView from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT +from rest_framework.views import APIView from openedx.core.djangoapps.content_libraries import api, permissions from openedx.core.lib.api.view_utils import view_auth_classes diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index f618365c128a..c903bd27cee1 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -2,7 +2,7 @@ Tests for openedx_content-based Content Libraries """ import textwrap -from datetime import datetime, timezone +from datetime import UTC, datetime import ddt from freezegun import freeze_time @@ -38,8 +38,8 @@ class ContainersTestCase(ContentLibrariesRestApiTest): def setUp(self) -> None: super().setUp() - self.create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc) # noqa: UP017 - self.modified_date = datetime(2024, 10, 9, 8, 7, 6, tzinfo=timezone.utc) # noqa: UP017 + self.create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=UTC) + self.modified_date = datetime(2024, 10, 9, 8, 7, 6, tzinfo=UTC) self.lib = self._create_library( slug="containers", title="Container Test Library", @@ -159,7 +159,7 @@ def test_container_crud(self, container_type, slug, display_name) -> None: Test Create, Read, Update, and Delete of a Containers """ # Create container: - create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc) # noqa: UP017 + create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=UTC) with freeze_time(create_date): container_data = self._create_container( self.lib["id"], @@ -190,7 +190,7 @@ def test_container_crud(self, container_type, slug, display_name) -> None: self.assertDictContainsEntries(container_as_read, expected_data) # Update the container: - modified_date = datetime(2024, 10, 9, 8, 7, 6, tzinfo=timezone.utc) # noqa: UP017 + modified_date = datetime(2024, 10, 9, 8, 7, 6, tzinfo=UTC) with freeze_time(modified_date): container_data = self._update_container(container_id, display_name=f"New Display Name for {container_type}") expected_data["last_draft_created"] = expected_data["modified"] = "2024-10-09T08:07:06Z" @@ -1029,16 +1029,16 @@ def test_container_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. """ - with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + with freeze_time(datetime(2026, 1, 1, tzinfo=UTC)): unit = self._create_container(self.lib["id"], "unit", display_name="History Unit Edits", slug=None) - with freeze_time(datetime(2026, 2, 1, tzinfo=timezone.utc)): + with freeze_time(datetime(2026, 2, 1, tzinfo=UTC)): self._publish_container(unit["id"]) - edit1_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc) + edit1_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=UTC) with freeze_time(edit1_time): self._update_container(unit["id"], display_name="History Unit Edits v2") - edit2_time = datetime(2026, 4, 2, 10, 0, 0, tzinfo=timezone.utc) + edit2_time = datetime(2026, 4, 2, 10, 0, 0, tzinfo=UTC) with freeze_time(edit2_time): self._update_container(unit["id"], display_name="History Unit Edits v3") @@ -1056,18 +1056,18 @@ def test_container_draft_history_includes_descendant_components(self): The history of a container includes entries from its descendant components, merged and sorted newest-first. """ - with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + with freeze_time(datetime(2026, 1, 1, tzinfo=UTC)): unit = self._create_container(self.lib["id"], "unit", display_name="History Unit Children", slug=None) block = self._add_block_to_library(self.lib["id"], "problem", "hist-prob", can_stand_alone=False) self._add_container_children(unit["id"], children_ids=[block["id"]]) - with freeze_time(datetime(2026, 2, 1, tzinfo=timezone.utc)): + with freeze_time(datetime(2026, 2, 1, tzinfo=UTC)): self._publish_container(unit["id"]) - container_edit_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc) + container_edit_time = datetime(2026, 4, 1, 10, 0, 0, tzinfo=UTC) with freeze_time(container_edit_time): self._update_container(unit["id"], display_name="History Unit Children v2") - block_edit_time = datetime(2026, 4, 2, 10, 0, 0, tzinfo=timezone.utc) + block_edit_time = datetime(2026, 4, 2, 10, 0, 0, tzinfo=UTC) with freeze_time(block_edit_time): self._set_library_block_olx(block["id"], "

edited

") 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 18f559538d07..b01f8b868607 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -5,7 +5,7 @@ import tempfile import uuid import zipfile -from datetime import datetime, timezone +from datetime import UTC, datetime from io import StringIO from unittest import skip from unittest.mock import ANY, patch @@ -318,7 +318,7 @@ def test_library_blocks(self): # pylint: disable=too-many-statements assert self._get_library_blocks(lib_id)['results'] == [] # Add a 'problem' XBlock to the library: - create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=timezone.utc) # noqa: UP017 + create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=UTC) with freeze_time(create_date): block_data = self._add_block_to_library(lib_id, "problem", "ࠒröblæm1") self.assertDictContainsEntries(block_data, { @@ -338,7 +338,7 @@ def test_library_blocks(self): # pylint: disable=too-many-statements assert self._get_library(lib_id)['has_unpublished_changes'] is True # Publish the changes: - publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=timezone.utc) # noqa: UP017 + publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=UTC) with freeze_time(publish_date): self._commit_library_changes(lib_id) assert self._get_library(lib_id)['has_unpublished_changes'] is False @@ -367,7 +367,7 @@ def test_library_blocks(self): # pylint: disable=too-many-statements """.strip() - update_date = datetime(2024, 8, 8, 8, 8, 8, tzinfo=timezone.utc) # noqa: UP017 + update_date = datetime(2024, 8, 8, 8, 8, 8, tzinfo=UTC) with freeze_time(update_date): self._set_library_block_olx(block_id, new_olx) # now reading it back, we should get that exact OLX (no change to whitespace etc.): @@ -409,7 +409,7 @@ def test_library_blocks(self): # pylint: disable=too-many-statements assert self._get_library_block_olx(block_id) == new_olx unpublished_block_data = self._get_library_block(block_id) assert unpublished_block_data['has_unpublished_changes'] is True - block_update_date = datetime(2024, 8, 8, 8, 8, 9, tzinfo=timezone.utc) # noqa: UP017 + block_update_date = datetime(2024, 8, 8, 8, 8, 9, tzinfo=UTC) with freeze_time(block_update_date): self._publish_library_block(block_id) # Confirm the block is now published: @@ -432,7 +432,7 @@ def test_library_blocks_studio_view(self): assert self._get_library_blocks(lib_id)['results'] == [] # Add a 'html' XBlock to the library: - create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=timezone.utc) # noqa: UP017 + create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=UTC) with freeze_time(create_date): block_data = self._add_block_to_library(lib_id, "problem", "problem1") self.assertDictContainsEntries(block_data, { @@ -452,7 +452,7 @@ def test_library_blocks_studio_view(self): assert self._get_library(lib_id)['has_unpublished_changes'] is True # Publish the changes: - publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=timezone.utc) # noqa: UP017 + publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=UTC) with freeze_time(publish_date): self._commit_library_changes(lib_id) assert self._get_library(lib_id)['has_unpublished_changes'] is False @@ -469,7 +469,7 @@ def test_library_blocks_studio_view(self): assert '

edit 1

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

edit 2

") @@ -1019,7 +1019,7 @@ def test_publish_history_after_single_publish(self): 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) + publish_time = datetime(2026, 1, 10, 12, 0, 0, tzinfo=UTC) with freeze_time(publish_time): self._publish_library_block(block_key) @@ -1045,13 +1045,13 @@ def test_publish_history_multiple_publishes(self): 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) + first_publish = datetime(2026, 1, 1, 0, 0, 0, tzinfo=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) + second_publish = datetime(2026, 2, 1, 0, 0, 0, tzinfo=UTC) with freeze_time(second_publish): self._publish_library_block(block_key) @@ -1070,7 +1070,7 @@ def test_publish_history_tracks_contributors(self): block = self._add_block_to_library(lib["id"], "problem", "prob1") block_key = block["id"] - with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + with freeze_time(datetime(2026, 1, 1, tzinfo=UTC)): self._publish_library_block(block_key) history = self._get_block_publish_history(block_key) @@ -1087,12 +1087,12 @@ def test_publish_history_entries(self): block = self._add_block_to_library(lib["id"], "problem", "prob1") block_key = block["id"] - with freeze_time(datetime(2026, 2, 15, tzinfo=timezone.utc)): + with freeze_time(datetime(2026, 2, 15, tzinfo=UTC)): self._set_library_block_olx(block_key, "

edit 1

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

edit 2

") - with freeze_time(datetime(2026, 3, 1, tzinfo=timezone.utc)): + with freeze_time(datetime(2026, 3, 1, tzinfo=UTC)): self._publish_library_block(block_key) history = self._get_block_publish_history(block_key) @@ -1115,7 +1115,7 @@ def test_publish_history_entries_unknown_uuid(self): block = self._add_block_to_library(lib["id"], "problem", "prob1") block_key = block["id"] - with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + with freeze_time(datetime(2026, 1, 1, tzinfo=UTC)): self._publish_library_block(block_key) fake_uuid = str(uuid.uuid4()) @@ -1136,7 +1136,7 @@ def test_publish_history_permissions(self): block = self._add_block_to_library(lib["id"], "problem", "prob1") block_key = block["id"] - with freeze_time(datetime(2026, 1, 1, tzinfo=timezone.utc)): + with freeze_time(datetime(2026, 1, 1, tzinfo=UTC)): self._publish_library_block(block_key) unauthorized = UserFactory.create(username="noauth-hist", password="edx") @@ -1273,13 +1273,13 @@ def test_post_verawood_multiple_publishes_stay_separate(self): block_key = block["id"] self._add_container_children(unit_key, [block_key]) - first_publish = datetime(2026, 1, 1, tzinfo=timezone.utc) + first_publish = datetime(2026, 1, 1, tzinfo=UTC) with freeze_time(first_publish): self._publish_container(unit_key) self._set_library_block_olx(block_key, "

v2

") - second_publish = datetime(2026, 2, 1, tzinfo=timezone.utc) + second_publish = datetime(2026, 2, 1, tzinfo=UTC) with freeze_time(second_publish): self._publish_container(unit_key) From e9e90616be120d3950617b2a248fc8998fd050d2 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 23 Apr 2026 11:34:00 -0500 Subject: [PATCH 13/31] refactor: Add `entity__component__component_type` to history log functions --- .../content_libraries/api/blocks.py | 19 ++++++++++++++----- .../content_libraries/api/containers.py | 15 ++++++++++++--- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 40356c64e872..58cb3f093027 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -221,7 +221,10 @@ def get_library_component_draft_history( except ObjectDoesNotExist as exc: raise ContentLibraryBlockNotFound(usage_key) from exc - records = list(content_api.get_entity_draft_history(component.publishable_entity)) + records = list( + content_api.get_entity_draft_history(component.publishable_entity) + .select_related("entity__component__component_type") + ) changed_by_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request ) @@ -266,7 +269,10 @@ def get_library_component_publish_history( raise ContentLibraryBlockNotFound(usage_key) from exc entity = component.publishable_entity - publish_records = list(content_api.get_entity_publish_history(entity)) + publish_records = list( + content_api.get_entity_publish_history(entity) + .select_related("entity__component__component_type") + ) groups = [] for pub_record in publish_records: @@ -340,9 +346,12 @@ def get_library_component_publish_history_entries( except ObjectDoesNotExist as exc: raise ContentLibraryBlockNotFound(usage_key) from exc - records = list(content_api.get_entity_publish_history_entries( - component.publishable_entity, publish_log_uuid - )) + records = list( + content_api.get_entity_publish_history_entries( + component.publishable_entity, publish_log_uuid + ) + .select_related("entity__component__component_type") + ) changed_by_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request ) diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index ba5077d496f1..12346337b336 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -541,7 +541,10 @@ def get_library_container_draft_history( results: list[LibraryHistoryEntry] = [] # Process the container itself first, then each descendant component. for item_id in [container.pk] + component_entity_ids: - records = content_api.get_entity_draft_history(item_id) + records = content_api.get_entity_draft_history(item_id).select_related( + "entity__component__component_type", + "entity__container__container_type", + ) # Resolve user profiles for all authors in one batch to avoid N+1 queries. changed_by_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request @@ -598,7 +601,10 @@ def get_library_container_publish_history( # Collect all records grouped by publish_log_uuid. publish_log_groups: dict[str, list[tuple[int, PublishLogRecord]]] = {} for entity_id in all_entity_ids: - for pub_record in content_api.get_entity_publish_history(entity_id): + for pub_record in content_api.get_entity_publish_history(entity_id).select_related( + "entity__component__component_type", + "entity__container__container_type", + ): uuid = str(pub_record.publish_log.uuid) publish_log_groups.setdefault(uuid, []).append((entity_id, pub_record)) @@ -764,7 +770,10 @@ def get_library_container_publish_history_entries( try: records = list( content_api.get_entity_publish_history_entries(entity_id, publish_log_uuid) - .select_related('entity__container__container_type') + .select_related( + 'entity__component__component_type', + 'entity__container__container_type', + ) ) except ObjectDoesNotExist: continue From 2345adff62f81d563ebdb5ae6ade8296fa51fe99 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 23 Apr 2026 17:15:29 -0500 Subject: [PATCH 14/31] fix: Small fix in block_metadata about local_key --- openedx/core/djangoapps/content_libraries/api/block_metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index e187c0cf37f7..a94e8305e5e4 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -216,7 +216,7 @@ def direct_published_entity_from_record( entity_key=str(LibraryUsageLocatorV2( # type: ignore[abstract] lib_key=lib_key, block_type=component.component_type.name, - usage_id=component.local_key, + usage_id=component.component_code, )), title=title, entity_type=component.component_type.name, From 8f7a67eee6978e1e7bcb066c9ea5331ebd6373a2 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 23 Apr 2026 19:13:52 -0500 Subject: [PATCH 15/31] fix: Broken lint and tests --- .../content_libraries/api/block_metadata.py | 3 ++- .../content_libraries/api/blocks.py | 25 +++++++++++-------- .../tests/test_content_libraries.py | 10 ++++---- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index a94e8305e5e4..f92be21d6b87 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -10,9 +10,10 @@ from django.utils.translation import gettext as _ # noqa: F401 from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content.models_api import Component, PublishLogRecord + from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user -from .libraries import library_component_usage_key, PublishableItem +from .libraries import PublishableItem, library_component_usage_key # The public API is only the following symbols: __all__ = [ diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 849ccc76ee4d..f329a0c1e7f3 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -25,7 +25,12 @@ from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content import api as content_api from openedx_content.models_api import ( - Collection, Component, ComponentVersion, Container, LearningPackage, MediaType, + Collection, + Component, + ComponentVersion, + Container, + LearningPackage, + MediaType, ) from openedx_events.content_authoring.data import ( ContentObjectChangedData, @@ -55,16 +60,23 @@ from ..models import ContentLibrary from .block_metadata import ( DirectPublishedEntity, - direct_published_entity_from_record, LibraryHistoryEntry, LibraryPublishHistoryGroup, LibraryXBlockMetadata, LibraryXBlockStaticFile, - resolve_contributors, + direct_published_entity_from_record, resolve_change_action, + resolve_contributors, ) from .collections import library_collection_locator from .container_metadata import container_subclass_for_olx_tag +from .containers import ( + ContainerMetadata, + create_container, + get_container, + get_containers_contains_item, + update_container_children, +) from .exceptions import ( BlockLimitReachedError, ContentLibraryBlockNotFound, @@ -72,13 +84,6 @@ InvalidNameError, LibraryBlockAlreadyExists, ) -from .containers import ( - create_container, - get_container, - get_containers_contains_item, - update_container_children, - ContainerMetadata, -) from .libraries import PublishableItem # This content_libraries API is sometimes imported in the LMS (should we prevent that?), but the content_staging app 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 b01f8b868607..2d9c017f88cf 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -320,9 +320,9 @@ def test_library_blocks(self): # pylint: disable=too-many-statements # Add a 'problem' XBlock to the library: create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=UTC) with freeze_time(create_date): - block_data = self._add_block_to_library(lib_id, "problem", "ࠒröblæm1") + block_data = self._add_block_to_library(lib_id, "problem", "problem1") self.assertDictContainsEntries(block_data, { - "id": "lb:CL-TEST:téstlꜟط:problem:ࠒröblæm1", + "id": "lb:CL-TEST:téstlꜟط:problem:problem1", "display_name": "Blank Problem", "block_type": "problem", "has_unpublished_changes": True, @@ -1437,7 +1437,7 @@ def test_container_creation_entry_returns_first_version(self): saved, with action='created' and item_type matching the container type. """ lib = self._create_library(slug="ct-creation-basic", title="Container Creation Basic") - unit = self._create_container(lib["id"], "unit", slug="unit1", title="My Unit") + unit = self._create_container(lib["id"], "unit", slug="unit1", display_name="My Unit") unit_key = unit["id"] entry = self._get_container_creation_entry(unit_key) @@ -1455,7 +1455,7 @@ def test_container_creation_entry_unchanged_after_edits(self): reflects the first saved version of the container. """ lib = self._create_library(slug="ct-creation-stable", title="Container Creation Stable") - unit = self._create_container(lib["id"], "unit", slug="unit1", title="Original Title") + unit = self._create_container(lib["id"], "unit", slug="unit1", display_name="Original Title") unit_key = unit["id"] entry_before = self._get_container_creation_entry(unit_key) @@ -1474,7 +1474,7 @@ def test_container_creation_entry_permissions(self): A user without library access receives 403 for the container creation entry. """ lib = self._create_library(slug="ct-creation-auth", title="Container Creation Auth") - unit = self._create_container(lib["id"], "unit", slug="unit1", title="Auth Unit") + unit = self._create_container(lib["id"], "unit", slug="unit1", display_name="Auth Unit") unit_key = unit["id"] unauthorized = UserFactory.create(username="noauth-ct-creation", password="edx") From 8f8019dbe43426b50e0a720b7d7dd747c6f2c8d2 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 13:17:23 -0500 Subject: [PATCH 16/31] chore: Update version of openedx-core --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/kernel.in | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 31c6b86d6b32..71790d659ff7 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -840,7 +840,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/kernel.in # xblocks-contrib -openedx-core @ git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log +openedx-core==0.44.0 # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index e33c45c90ee9..3ad4c94197fd 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1394,7 +1394,7 @@ openedx-calc==5.0.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # xblocks-contrib -openedx-core @ git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log +openedx-core==0.44.0 # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 6f832e05650a..b7f29a68f1df 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1018,7 +1018,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core @ git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log +openedx-core==0.44.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 565b4ca041e7..8abecd6ea77a 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -113,7 +113,7 @@ oauthlib # OAuth specification support for authentica olxcleaner openedx-atlas # CLI tool to manage translations openedx-calc # Library supporting mathematical calculations for Open edX -git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log#egg=openedx-core +openedx-core openedx-django-require openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 62c10fd78404..1e14484defc2 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1065,7 +1065,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core @ git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log +openedx-core==0.44.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt From 43567d8ccb134fa2ee9220a07dfd11c582c7cac3 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 13:39:51 -0500 Subject: [PATCH 17/31] refactor: set `entity_key` and `scope_entity_key` as respective key types --- .../content_libraries/api/block_metadata.py | 14 +-- .../content_libraries/api/blocks.py | 6 +- .../content_libraries/rest_api/serializers.py | 98 +++++++++---------- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index f92be21d6b87..b6dc8fbffab7 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -8,7 +8,7 @@ from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ # noqa: F401 -from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content.models_api import Component, PublishLogRecord from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user @@ -112,7 +112,7 @@ class DirectPublishedEntity: Pre-Verawood groups have exactly one entry (approximated from available data). Post-Verawood groups have one entry per direct=True record in the PublishLog. """ - entity_key: str # str(usage_key) for components, str(container_key) for containers + entity_key: LibraryUsageLocatorV2 | LibraryContainerLocator title: str # title of the entity at time of publish entity_type: str # e.g. "html", "problem" for components; "unit", "section" for containers @@ -141,8 +141,8 @@ class LibraryPublishHistoryGroup: # Key to pass as scope_entity_key when fetching entries for this group. # Pre-Verawood: the specific entity key for this group (container or usage key). # Post-Verawood container groups: None — frontend must use currentContainerKey. - # Component history (all eras): str(usage_key). - scope_entity_key: str | None + # Component history (all eras): usage_key. + scope_entity_key: LibraryUsageLocatorV2 | LibraryContainerLocator | None @dataclass(frozen=True) @@ -214,18 +214,18 @@ def direct_published_entity_from_record( try: component = record.entity.component return DirectPublishedEntity( - entity_key=str(LibraryUsageLocatorV2( # type: ignore[abstract] + entity_key=LibraryUsageLocatorV2( # type: ignore[abstract] lib_key=lib_key, block_type=component.component_type.name, usage_id=component.component_code, - )), + ), title=title, entity_type=component.component_type.name, ) except Component.DoesNotExist: container = record.entity.container return DirectPublishedEntity( - entity_key=str(library_container_locator(lib_key, container)), + entity_key=library_container_locator(lib_key, container), title=title, entity_type=container.container_type.type_code, ) diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index f329a0c1e7f3..d478e6896be3 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -299,7 +299,7 @@ def get_library_component_publish_history( if pub_record.direct is None or pub_record.direct is True: # Pre-Verawood or component was directly published: single entry for itself. direct_published_entities = [DirectPublishedEntity( - entity_key=str(usage_key), + entity_key=usage_key, title=pub_record.new_version.title if pub_record.new_version else "", entity_type=pub_record.entity.component.component_type.name, )] @@ -319,7 +319,7 @@ def get_library_component_publish_history( direct_published_entity_from_record(r, usage_key.lib_key) for r in direct_records ] or [DirectPublishedEntity( - entity_key=str(usage_key), + entity_key=usage_key, title=pub_record.new_version.title if pub_record.new_version else "", entity_type=pub_record.entity.component.component_type.name, )] @@ -331,7 +331,7 @@ def get_library_component_publish_history( contributors=contributors, contributors_count=len(contributors), direct_published_entities=direct_published_entities, - scope_entity_key=str(usage_key), + scope_entity_key=usage_key, )) return groups diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index a1038c9a58fc..c8d67b2d8c97 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -196,11 +196,58 @@ class LibraryHistoryEntrySerializer(serializers.Serializer): action = serializers.CharField(read_only=True) +class UsageKeyV2Serializer(serializers.BaseSerializer): + """ + Serializes a library Component (XBlock) key. + """ + def to_representation(self, value: LibraryUsageLocatorV2) -> str: + """ + Returns the LibraryUsageLocatorV2 value as a string. + """ + return str(value) + + def to_internal_value(self, value: str) -> LibraryUsageLocatorV2: + """ + Returns a LibraryUsageLocatorV2 from the string value. + + Raises ValidationError if invalid LibraryUsageLocatorV2. + """ + try: + return LibraryUsageLocatorV2.from_string(value) + except InvalidKeyError as err: + raise ValidationError from err + + +class OpaqueKeySerializer(serializers.BaseSerializer): + """ + Serializes a OpaqueKey with the correct class. + """ + def to_representation(self, value: OpaqueKey) -> str: + """ + Returns the OpaqueKey value as a string. + """ + return str(value) + + def to_internal_value(self, value: str) -> OpaqueKey: + """ + Returns a LibraryUsageLocatorV2 or a LibraryContainerLocator from the string value. + + Raises ValidationError if invalid UsageKeyV2 or LibraryContainerLocator. + """ + try: + return LibraryUsageLocatorV2.from_string(value) + except InvalidKeyError: + try: + return LibraryContainerLocator.from_string(value) + except InvalidKeyError as err: + raise ValidationError from err + + class DirectPublishedEntitySerializer(serializers.Serializer): """ Serializer for one entity the user directly requested to publish (direct=True). """ - entity_key = serializers.CharField(read_only=True) + entity_key = OpaqueKeySerializer(read_only=True) title = serializers.CharField(read_only=True) entity_type = serializers.CharField(read_only=True) @@ -215,7 +262,7 @@ class LibraryPublishHistoryGroupSerializer(serializers.Serializer): contributors = LibraryHistoryContributorSerializer(many=True, read_only=True) contributors_count = serializers.IntegerField(read_only=True) direct_published_entities = DirectPublishedEntitySerializer(many=True, read_only=True) - scope_entity_key = serializers.CharField(read_only=True, allow_null=True) + scope_entity_key = OpaqueKeySerializer(read_only=True, allow_null=True) def get_published_by(self, obj) -> str | None: return obj.published_by.username if obj.published_by else None @@ -347,53 +394,6 @@ class ContentLibraryCollectionUpdateSerializer(serializers.Serializer): description = serializers.CharField(allow_blank=True) -class UsageKeyV2Serializer(serializers.BaseSerializer): - """ - Serializes a library Component (XBlock) key. - """ - def to_representation(self, value: LibraryUsageLocatorV2) -> str: - """ - Returns the LibraryUsageLocatorV2 value as a string. - """ - return str(value) - - def to_internal_value(self, value: str) -> LibraryUsageLocatorV2: - """ - Returns a LibraryUsageLocatorV2 from the string value. - - Raises ValidationError if invalid LibraryUsageLocatorV2. - """ - try: - return LibraryUsageLocatorV2.from_string(value) - except InvalidKeyError as err: - raise ValidationError from err - - -class OpaqueKeySerializer(serializers.BaseSerializer): - """ - Serializes a OpaqueKey with the correct class. - """ - def to_representation(self, value: OpaqueKey) -> str: - """ - Returns the OpaqueKey value as a string. - """ - return str(value) - - def to_internal_value(self, value: str) -> OpaqueKey: - """ - Returns a LibraryUsageLocatorV2 or a LibraryContainerLocator from the string value. - - Raises ValidationError if invalid UsageKeyV2 or LibraryContainerLocator. - """ - try: - return LibraryUsageLocatorV2.from_string(value) - except InvalidKeyError: - try: - return LibraryContainerLocator.from_string(value) - except InvalidKeyError as err: - raise ValidationError from err - - class ContentLibraryItemContainerKeysSerializer(serializers.Serializer): """ Serializer for adding/removing items to/from a Container. From fab0165ca7702ebd6fb398c2045247e1fb6e77c6 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 14:03:47 -0500 Subject: [PATCH 18/31] refactor: Set `publish_log_uuid` to `UUID` type --- .../content_libraries/api/block_metadata.py | 3 ++- .../djangoapps/content_libraries/api/blocks.py | 8 ++++---- .../djangoapps/content_libraries/api/containers.py | 14 +++++++------- .../content_libraries/rest_api/blocks.py | 10 ++++++++-- .../content_libraries/rest_api/serializers.py | 2 +- 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index b6dc8fbffab7..2406a4062684 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import datetime +from uuid import UUID from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ # noqa: F401 @@ -129,7 +130,7 @@ class LibraryPublishHistoryGroup: Pre-Verawood (direct=None): one group per entity × publish event. Post-Verawood (direct!=None): one group per unique PublishLog. """ - publish_log_uuid: str + publish_log_uuid: UUID published_by: object # AUTH_USER_MODEL instance or None published_at: datetime contributors: list[LibraryHistoryContributor] # distinct authors of versions in this group diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index d478e6896be3..e6abe374feb0 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -9,7 +9,7 @@ import mimetypes from datetime import datetime, timezone from typing import TYPE_CHECKING -from uuid import uuid4 +from uuid import UUID, uuid4 from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -325,7 +325,7 @@ def get_library_component_publish_history( )] groups.append(LibraryPublishHistoryGroup( - publish_log_uuid=str(pub_record.publish_log.uuid), + publish_log_uuid=pub_record.publish_log.uuid, published_by=pub_record.publish_log.published_by, published_at=pub_record.publish_log.published_at, contributors=contributors, @@ -339,7 +339,7 @@ def get_library_component_publish_history( def get_library_component_publish_history_entries( usage_key: LibraryUsageLocatorV2, - publish_log_uuid: str, + publish_log_uuid: UUID, request=None, ) -> list[LibraryHistoryEntry]: """ @@ -356,7 +356,7 @@ def get_library_component_publish_history_entries( records = list( content_api.get_entity_publish_history_entries( - component.publishable_entity, publish_log_uuid + component.publishable_entity, str(publish_log_uuid) ) .select_related("entity__component__component_type") ) diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 5be0b68b9210..91678c17a7ec 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -7,7 +7,7 @@ import logging import typing from datetime import datetime, timezone -from uuid import uuid4 +from uuid import UUID, uuid4 from django.core.exceptions import ObjectDoesNotExist from django.db import transaction @@ -603,13 +603,13 @@ def get_library_container_publish_history( all_entity_ids = [container.pk] + component_entity_ids # Collect all records grouped by publish_log_uuid. - publish_log_groups: dict[str, list[tuple[int, PublishLogRecord]]] = {} + publish_log_groups: dict[UUID, list[tuple[int, PublishLogRecord]]] = {} for entity_id in all_entity_ids: for pub_record in content_api.get_entity_publish_history(entity_id).select_related( "entity__component__component_type", "entity__container__container_type", ): - uuid = str(pub_record.publish_log.uuid) + uuid: UUID = pub_record.publish_log.uuid publish_log_groups.setdefault(uuid, []).append((entity_id, pub_record)) groups = [] @@ -638,7 +638,7 @@ def get_library_container_publish_history( def _build_post_verawood_container_group( - uuid: str, + uuid: UUID, entity_records: list[tuple[int, PublishLogRecord]], container_key: LibraryContainerLocator, request, @@ -714,7 +714,7 @@ def _build_pre_verawood_container_group( entity = direct_published_entity_from_record(pub_record, container_key.lib_key) return LibraryPublishHistoryGroup( - publish_log_uuid=str(pub_record.publish_log.uuid), + publish_log_uuid=pub_record.publish_log.uuid, published_by=pub_record.publish_log.published_by, published_at=pub_record.publish_log.published_at, contributors=contributors, @@ -727,7 +727,7 @@ def _build_pre_verawood_container_group( def get_library_container_publish_history_entries( scope_entity_key: LibraryContainerLocator, - publish_log_uuid: str, + publish_log_uuid: UUID, request=None, ) -> list[LibraryHistoryEntry]: """ @@ -773,7 +773,7 @@ def get_library_container_publish_history_entries( for entity_id in relevant_entity_ids: try: records = list( - content_api.get_entity_publish_history_entries(entity_id, publish_log_uuid) + content_api.get_entity_publish_history_entries(entity_id, str(publish_log_uuid)) .select_related( 'entity__component__component_type', 'entity__container__container_type', diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index ca1ef3b615f6..d123975e8cef 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -1,6 +1,8 @@ """ Content Library REST APIs related to XBlocks/Components and their static assets """ +from uuid import UUID + import edx_api_doc_tools as apidocs from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -208,9 +210,13 @@ def get(self, request, lib_key_str): lib_key = LibraryLocatorV2.from_string(lib_key_str) api.require_permission_for_library_key(lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) scope_entity_key_str = request.query_params.get("scope_entity_key", "") - publish_log_uuid = request.query_params.get("publish_log_uuid", "") - if not scope_entity_key_str or not publish_log_uuid: + publish_log_uuid_str = request.query_params.get("publish_log_uuid", "") + if not scope_entity_key_str or not publish_log_uuid_str: return Response({"error": "scope_entity_key and publish_log_uuid are required."}, status=400) + try: + publish_log_uuid = UUID(publish_log_uuid_str) + except ValueError: + return Response({"error": f"Invalid publish_log_uuid: {publish_log_uuid_str!r}"}, status=400) try: usage_key = LibraryUsageLocatorV2.from_string(scope_entity_key_str) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index c8d67b2d8c97..e30d07b02401 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -256,7 +256,7 @@ class LibraryPublishHistoryGroupSerializer(serializers.Serializer): """ Serializer for a publish event summary in the publish history of a library item. """ - publish_log_uuid = serializers.CharField(read_only=True) + publish_log_uuid = serializers.UUIDField(read_only=True) published_by = serializers.SerializerMethodField() published_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) contributors = LibraryHistoryContributorSerializer(many=True, read_only=True) From 50355eedf736be95b97ff1691af057feaa98438f Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 14:12:27 -0500 Subject: [PATCH 19/31] refactor: Set `published_by` as `AbstractUser` --- .../core/djangoapps/content_libraries/api/block_metadata.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 2406a4062684..12bd40d4b8d3 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -8,6 +8,7 @@ from uuid import UUID from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext as _ # noqa: F401 from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content.models_api import Component, PublishLogRecord @@ -131,7 +132,7 @@ class LibraryPublishHistoryGroup: Post-Verawood (direct!=None): one group per unique PublishLog. """ publish_log_uuid: UUID - published_by: object # AUTH_USER_MODEL instance or None + published_by: AbstractUser | None published_at: datetime contributors: list[LibraryHistoryContributor] # distinct authors of versions in this group contributors_count: int From 73ad2c99c31073e09d2018d47ff520b0681d4fb2 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 14:19:41 -0500 Subject: [PATCH 20/31] style: Add comments --- .../content_libraries/api/block_metadata.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 12bd40d4b8d3..653d90230d33 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -165,7 +165,17 @@ class LibraryXBlockStaticFile: def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor | None]: """ Convert an iterable of User objects (possibly containing None) to a list of - LibraryHistoryContributor. + LibraryHistoryContributor, batching the profile fetch in a single query. + + Ordering: output preserves the order of the input iterable. + + Duplicates: preserved intentionally. Each output element corresponds to the + element at the same position in the input (e.g. each history entry carries + its own contributor, and the same user may have authored multiple entries). + + None entries: preserved intentionally. A None input (user unknown, e.g. an + import or migration) produces a None output, which the frontend renders as + a default/anonymous contributor. """ users_list = list(users) user_pks = list({user.pk for user in users_list if user is not None}) From 9f448b2f14ce47e7856be8572fa45ddaaea5c29f Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 14:24:25 -0500 Subject: [PATCH 21/31] refactor: simplify resolve_contributors body per code review --- .../djangoapps/content_libraries/api/block_metadata.py | 7 +++++-- 1 file changed, 5 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 653d90230d33..330a42236383 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -178,11 +178,14 @@ def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor a default/anonymous contributor. """ users_list = list(users) - user_pks = list({user.pk for user in users_list if user is not None}) + # Deduplicated PKs for a single batched profile fetch. + user_pks = {user.pk for user in users_list if user is not None} + if not user_pks: + return [None] * len(users_list) prefetched = { user.pk: user for user in User.objects.filter(pk__in=user_pks).select_related('profile') - } if user_pks else {} + } return [ LibraryHistoryContributor.from_user(prefetched.get(user.pk, user), request) if user else None From 438f9592950b5307af83f68883bcb4dc81cf94db Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 14:30:07 -0500 Subject: [PATCH 22/31] refactor: simplify prefetched lookup to use direct key access --- .../core/djangoapps/content_libraries/api/block_metadata.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 330a42236383..0370d56de605 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -182,12 +182,15 @@ def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor user_pks = {user.pk for user in users_list if user is not None} if not user_pks: return [None] * len(users_list) + # Fetch all users with their profiles in one query, keyed by PK. prefetched = { user.pk: user for user in User.objects.filter(pk__in=user_pks).select_related('profile') } + # Build the output in input order. None entries (unknown author) are passed + # through as-is; real users are looked up in the prefetched dict. return [ - LibraryHistoryContributor.from_user(prefetched.get(user.pk, user), request) + LibraryHistoryContributor.from_user(prefetched[user.pk], request) if user else None for user in users_list ] From eedc9d0c613db0fe80de4930d68134fe457f1fb3 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 14:51:49 -0500 Subject: [PATCH 23/31] refactor: move profile select_related to upstream querysets, simplify resolve_contributor --- .../content_libraries/api/block_metadata.py | 25 +++++-------------- .../content_libraries/api/blocks.py | 8 +++--- .../content_libraries/api/containers.py | 9 ++++--- 3 files changed, 15 insertions(+), 27 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 0370d56de605..8479bd7b0dfa 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -7,7 +7,6 @@ from datetime import datetime from uuid import UUID -from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext as _ # noqa: F401 from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 @@ -26,9 +25,6 @@ "LibraryPublishHistoryGroup", ] -User = get_user_model() - - @dataclass(frozen=True, kw_only=True) class LibraryXBlockMetadata(PublishableItem): """ @@ -165,7 +161,10 @@ class LibraryXBlockStaticFile: def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor | None]: """ Convert an iterable of User objects (possibly containing None) to a list of - LibraryHistoryContributor, batching the profile fetch in a single query. + LibraryHistoryContributor. + + Callers are responsible for loading profiles upstream via + select_related('profile') on the source queryset to avoid N+1 queries. Ordering: output preserves the order of the input iterable. @@ -177,22 +176,10 @@ def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor import or migration) produces a None output, which the frontend renders as a default/anonymous contributor. """ - users_list = list(users) - # Deduplicated PKs for a single batched profile fetch. - user_pks = {user.pk for user in users_list if user is not None} - if not user_pks: - return [None] * len(users_list) - # Fetch all users with their profiles in one query, keyed by PK. - prefetched = { - user.pk: user - for user in User.objects.filter(pk__in=user_pks).select_related('profile') - } - # Build the output in input order. None entries (unknown author) are passed - # through as-is; real users are looked up in the prefetched dict. return [ - LibraryHistoryContributor.from_user(prefetched[user.pk], request) + LibraryHistoryContributor.from_user(user, request) if user else None - for user in users_list + for user in users ] diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index e6abe374feb0..348d4df5cdc8 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -231,7 +231,7 @@ def get_library_component_draft_history( records = list( content_api.get_entity_draft_history(component.publishable_entity) - .select_related("entity__component__component_type") + .select_related("entity__component__component_type", "draft_change_log__changed_by__profile") ) changed_by_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request @@ -293,7 +293,7 @@ def get_library_component_publish_history( entity, old_version_num=old_version_num, new_version_num=new_version_num, - )) + ).select_related('profile')) contributors = [c for c in resolve_contributors(raw_contributors, request) if c is not None] if pub_record.direct is None or pub_record.direct is True: @@ -358,7 +358,7 @@ def get_library_component_publish_history_entries( content_api.get_entity_publish_history_entries( component.publishable_entity, str(publish_log_uuid) ) - .select_related("entity__component__component_type") + .select_related("entity__component__component_type", "draft_change_log__changed_by__profile") ) changed_by_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request @@ -398,7 +398,7 @@ def get_library_component_creation_entry( first_version = ( component.publishable_entity.versions .filter(version_num=1) - .select_related("created_by") + .select_related("created_by__profile") .first() ) if first_version is None: diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 91678c17a7ec..73af7516ee2c 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -548,8 +548,8 @@ def get_library_container_draft_history( records = content_api.get_entity_draft_history(item_id).select_related( "entity__component__component_type", "entity__container__container_type", + "draft_change_log__changed_by__profile", ) - # Resolve user profiles for all authors in one batch to avoid N+1 queries. changed_by_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request ) @@ -674,7 +674,7 @@ def _build_post_verawood_container_group( entity_id, old_version_num=old_version_num, new_version_num=new_version_num, - )) + ).select_related('profile')) for contributor in resolve_contributors(raw, request): if contributor is not None and contributor.username not in seen_usernames: seen_usernames.add(contributor.username) @@ -709,7 +709,7 @@ def _build_pre_verawood_container_group( entity_id, old_version_num=old_version_num, new_version_num=new_version_num, - )) + ).select_related('profile')) contributors = [c for c in resolve_contributors(raw, request) if c is not None] entity = direct_published_entity_from_record(pub_record, container_key.lib_key) @@ -777,6 +777,7 @@ def get_library_container_publish_history_entries( .select_related( 'entity__component__component_type', 'entity__container__container_type', + 'draft_change_log__changed_by__profile', ) ) except ObjectDoesNotExist: @@ -818,7 +819,7 @@ def get_library_container_creation_entry( first_version = ( container.publishable_entity.versions .filter(version_num=1) - .select_related("created_by") + .select_related("created_by__profile") .first() ) if first_version is None: From cb03488c7ae36066af33a1c050d7d142787dd92f Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 15:19:24 -0500 Subject: [PATCH 24/31] feat: handle created and deleted actions in resolve_change_action --- .../content_libraries/api/block_metadata.py | 15 ++++++----- .../tests/test_content_libraries.py | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 8479bd7b0dfa..dd443f8d477c 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -10,7 +10,7 @@ from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext as _ # noqa: F401 from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 -from openedx_content.models_api import Component, PublishLogRecord +from openedx_content.models_api import Component, PublishableEntityVersion, PublishLogRecord from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user @@ -81,7 +81,7 @@ class LibraryHistoryEntry: changed_at: datetime title: str # title at time of change item_type: str - action: str # "edited" | "renamed" + action: str # "created" | "edited" | "renamed" | "deleted" @dataclass(frozen=True) @@ -183,14 +183,15 @@ def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor ] -def resolve_change_action(old_version, new_version) -> str: +def resolve_change_action(old_version: PublishableEntityVersion | None, new_version: PublishableEntityVersion | None) -> str: """ Derive a human-readable action label from a draft history record's versions. - - Returns "renamed" when both versions exist and the title changed between - them; otherwise returns "edited" as the default action. """ - if old_version and new_version and old_version.title != new_version.title: + if old_version is None: + return "created" + if new_version is None: + return "deleted" + if old_version.title != new_version.title: return "renamed" return "edited" 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 2d9c017f88cf..539b8a9dfae1 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -966,6 +966,33 @@ def test_draft_history_action_edited(self): assert len(history) >= 1 assert history[0]["action"] == "edited" + def test_draft_history_action_created(self): + """ + When a block is first created (old_version=None), the action is 'created'. + """ + lib = self._create_library(slug="draft-hist-create", title="Draft History Create") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + history = self._get_block_draft_history(block_key) + assert len(history) >= 1 + assert history[-1]["action"] == "created" + + def test_draft_history_action_deleted(self): + """ + When a block is soft-deleted (new_version=None), the action is 'deleted'. + """ + lib = self._create_library(slug="draft-hist-delete", title="Draft History Delete") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + + self._publish_library_block(block_key) + self._delete_library_block(block_key) + + history = self._get_block_draft_history(block_key) + assert len(history) >= 1 + assert history[0]["action"] == "deleted" + def test_draft_history_cleared_after_publish(self): """ After publishing, the draft history resets to empty. From 140a7c8e72cd39d909366530f42475ad4216f704 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 15:44:46 -0500 Subject: [PATCH 25/31] fix: use old_version title as fallback for soft-deleted entities in publish history --- .../content_libraries/api/block_metadata.py | 5 ++++- .../content_libraries/api/blocks.py | 1 + .../content_libraries/api/containers.py | 3 +++ .../tests/test_content_libraries.py | 22 +++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index dd443f8d477c..b2d6f804c01f 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -211,12 +211,15 @@ def direct_published_entity_from_record( 'entity__component__component_type', 'entity__container__container_type', 'new_version', + 'old_version', ) """ # Import here to avoid circular imports (container_metadata imports block_metadata). from .container_metadata import library_container_locator # noqa: PLC0415 - title = record.new_version.title if record.new_version else "" + # Use new_version title when available; fall back to old_version for soft-deletes (new_version=None). + version = record.new_version or record.old_version + title = version.title if version else "" try: component = record.entity.component return DirectPublishedEntity( diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 348d4df5cdc8..c1c1aad91b9b 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -313,6 +313,7 @@ def get_library_component_publish_history( 'entity__component__component_type', 'entity__container__container_type', 'new_version', + 'old_version', ) ) direct_published_entities = [ diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 73af7516ee2c..d6ccc06cf851 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -608,6 +608,8 @@ def get_library_container_publish_history( for pub_record in content_api.get_entity_publish_history(entity_id).select_related( "entity__component__component_type", "entity__container__container_type", + "new_version", + "old_version", ): uuid: UUID = pub_record.publish_log.uuid publish_log_groups.setdefault(uuid, []).append((entity_id, pub_record)) @@ -658,6 +660,7 @@ def _build_post_verawood_container_group( 'entity__component__component_type', 'entity__container__container_type', 'new_version', + 'old_version', ) ) direct_published_entities = [ 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 539b8a9dfae1..e5fecf81d507 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -1064,6 +1064,28 @@ def test_publish_history_after_single_publish(self): assert entity["entity_key"] == block_key assert entity["entity_type"] == "problem" + def test_publish_history_deleted_block_retains_title(self): + """ + When a block is soft-deleted and published, the direct_published_entities + entry shows the block's last known title (from old_version), not an empty string. + """ + lib = self._create_library(slug="hist-delete-title", title="History Delete Title") + block = self._add_block_to_library(lib["id"], "problem", "prob1") + block_key = block["id"] + self._set_library_block_olx( + block_key, + '

content

', + ) + self._publish_library_block(block_key) + self._delete_library_block(block_key) + self._publish_library_block(block_key) + + history = self._get_block_publish_history(block_key) + # Most recent publish is the deletion + deletion_group = history[0] + assert len(deletion_group["direct_published_entities"]) == 1 + assert deletion_group["direct_published_entities"][0]["title"] == "My Problem Title" + def test_publish_history_multiple_publishes(self): """ Multiple publish events are returned newest-first. From 8ead06712e58027fa89ecdb5f114101ffd6efe43 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 16:01:15 -0500 Subject: [PATCH 26/31] refactor: replace try/except with hasattr() checks in direct_published_entity_from_record --- .../djangoapps/content_libraries/api/block_metadata.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index b2d6f804c01f..2edd6d5a3aae 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -10,7 +10,7 @@ from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext as _ # noqa: F401 from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 -from openedx_content.models_api import Component, PublishableEntityVersion, PublishLogRecord +from openedx_content.models_api import PublishableEntityVersion, PublishLogRecord from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user @@ -220,7 +220,7 @@ def direct_published_entity_from_record( # Use new_version title when available; fall back to old_version for soft-deletes (new_version=None). version = record.new_version or record.old_version title = version.title if version else "" - try: + if hasattr(record.entity, 'component'): component = record.entity.component return DirectPublishedEntity( entity_key=LibraryUsageLocatorV2( # type: ignore[abstract] @@ -231,10 +231,11 @@ def direct_published_entity_from_record( title=title, entity_type=component.component_type.name, ) - except Component.DoesNotExist: + if hasattr(record.entity, 'container'): container = record.entity.container return DirectPublishedEntity( entity_key=library_container_locator(lib_key, container), title=title, entity_type=container.container_type.type_code, ) + raise ValueError(f"PublishableEntity {record.entity.pk!r} is neither a Component nor a Container") From c425ec9ebd4fc724375690252cb81ba6cf6c003d Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 17:23:46 -0500 Subject: [PATCH 27/31] refactor: rename changed_by to contributor in LibraryHistoryEntry --- .../content_libraries/api/block_metadata.py | 2 +- .../djangoapps/content_libraries/api/blocks.py | 16 ++++++++-------- .../content_libraries/api/containers.py | 16 ++++++++-------- .../content_libraries/rest_api/serializers.py | 2 +- .../tests/test_content_libraries.py | 8 ++++---- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 2edd6d5a3aae..58cc14ac69b9 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -77,7 +77,7 @@ class LibraryHistoryEntry: """ One entry in the history of a library component. """ - changed_by: LibraryHistoryContributor | None + contributor: LibraryHistoryContributor | None changed_at: datetime title: str # title at time of change item_type: str diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index c1c1aad91b9b..5e7cde8a6152 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -233,15 +233,15 @@ def get_library_component_draft_history( content_api.get_entity_draft_history(component.publishable_entity) .select_related("entity__component__component_type", "draft_change_log__changed_by__profile") ) - changed_by_list = resolve_contributors( + contributor_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request ) entries = [] - for record, changed_by in zip(records, changed_by_list, strict=False): + for record, contributor in zip(records, contributor_list, strict=False): version = record.new_version if record.new_version is not None else record.old_version entries.append(LibraryHistoryEntry( - changed_by=changed_by, + contributor=contributor, changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", item_type=record.entity.component.component_type.name, @@ -361,15 +361,15 @@ def get_library_component_publish_history_entries( ) .select_related("entity__component__component_type", "draft_change_log__changed_by__profile") ) - changed_by_list = resolve_contributors( + contributor_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request ) entries = [] - for record, changed_by in zip(records, changed_by_list, strict=False): + for record, contributor in zip(records, contributor_list, strict=False): version = record.new_version if record.new_version is not None else record.old_version entries.append(LibraryHistoryEntry( - changed_by=changed_by, + contributor=contributor, changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", item_type=record.entity.component.component_type.name, @@ -405,9 +405,9 @@ def get_library_component_creation_entry( if first_version is None: return None - changed_by_list = resolve_contributors([first_version.created_by], request) + contributor_list = resolve_contributors([first_version.created_by], request) return LibraryHistoryEntry( - changed_by=changed_by_list[0], + contributor=contributor_list[0], changed_at=first_version.created, title=first_version.title, item_type=component.component_type.name, diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index d6ccc06cf851..78d865254481 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -550,12 +550,12 @@ def get_library_container_draft_history( "entity__container__container_type", "draft_change_log__changed_by__profile", ) - changed_by_list = resolve_contributors( + contributor_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request ) entries = [] - for record, changed_by in zip(records, changed_by_list, strict=False): + for record, contributor in zip(records, contributor_list, strict=False): # Use the new version when available; fall back to the old version # (e.g. for delete records where new_version is None). version = record.new_version if record.new_version is not None else record.old_version @@ -564,7 +564,7 @@ def get_library_container_draft_history( except Component.DoesNotExist: item_type = record.entity.container.container_type.type_code entries.append(LibraryHistoryEntry( - changed_by=changed_by, + contributor=contributor, changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", item_type=item_type, @@ -786,17 +786,17 @@ def get_library_container_publish_history_entries( except ObjectDoesNotExist: continue - changed_by_list = resolve_contributors( + contributor_list = resolve_contributors( (record.draft_change_log.changed_by for record in records), request ) - for record, changed_by in zip(records, changed_by_list, strict=False): + for record, contributor in zip(records, contributor_list, strict=False): version = record.new_version if record.new_version is not None else record.old_version try: item_type = record.entity.component.component_type.name except Component.DoesNotExist: item_type = record.entity.container.container_type.type_code entries.append(LibraryHistoryEntry( - changed_by=changed_by, + contributor=contributor, changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", item_type=item_type, @@ -828,9 +828,9 @@ def get_library_container_creation_entry( if first_version is None: return None - changed_by_list = resolve_contributors([first_version.created_by], request) + contributor_list = resolve_contributors([first_version.created_by], request) return LibraryHistoryEntry( - changed_by=changed_by_list[0], + contributor=contributor_list[0], changed_at=first_version.created, title=first_version.title, item_type=container.container_type.type_code, diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index e30d07b02401..6012f9369af2 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -189,7 +189,7 @@ class LibraryHistoryEntrySerializer(serializers.Serializer): """ Serializer for a single entry in the history of a library item. """ - changed_by = LibraryHistoryContributorSerializer(allow_null=True, read_only=True) + contributor = LibraryHistoryContributorSerializer(allow_null=True, read_only=True) changed_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) title = serializers.CharField(read_only=True) item_type = serializers.CharField(read_only=True) 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 e5fecf81d507..03cadffd4747 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -929,7 +929,7 @@ def test_draft_history_shows_unpublished_edits(self): 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 "contributor" in entry assert "title" in entry assert "action" in entry @@ -1151,7 +1151,7 @@ def test_publish_history_entries(self): 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 "contributor" in entry assert "changed_at" in entry assert "title" in entry assert "action" in entry @@ -1440,7 +1440,7 @@ def test_creation_entry_returns_first_version(self): assert entry["item_type"] == "problem" assert "changed_at" in entry assert "title" in entry - assert "changed_by" in entry + assert "contributor" in entry def test_creation_entry_unchanged_after_edits(self): """ @@ -1496,7 +1496,7 @@ def test_container_creation_entry_returns_first_version(self): assert entry["item_type"] == "unit" assert entry["title"] == "My Unit" assert "changed_at" in entry - assert "changed_by" in entry + assert "contributor" in entry def test_container_creation_entry_unchanged_after_edits(self): """ From 8abef05f7f90a45cf649bcb82f893690f929758a Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 18:00:19 -0500 Subject: [PATCH 28/31] refactor: replace resolve_contributors with make_contributor module-level helper --- .../content_libraries/api/block_metadata.py | 25 ++------- .../content_libraries/api/blocks.py | 42 ++++++++------ .../content_libraries/api/containers.py | 56 ++++++++++--------- 3 files changed, 60 insertions(+), 63 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 58cc14ac69b9..da794d419986 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -158,29 +158,14 @@ class LibraryXBlockStaticFile: size: int -def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor | None]: - """ - Convert an iterable of User objects (possibly containing None) to a list of - LibraryHistoryContributor. - - Callers are responsible for loading profiles upstream via - select_related('profile') on the source queryset to avoid N+1 queries. - Ordering: output preserves the order of the input iterable. - - Duplicates: preserved intentionally. Each output element corresponds to the - element at the same position in the input (e.g. each history entry carries - its own contributor, and the same user may have authored multiple entries). +def make_contributor(user, request=None) -> LibraryHistoryContributor | None: + """ + Convert a single User (or None) to a LibraryHistoryContributor. - None entries: preserved intentionally. A None input (user unknown, e.g. an - import or migration) produces a None output, which the frontend renders as - a default/anonymous contributor. + None input produces None output — frontend renders as default/anonymous. """ - return [ - LibraryHistoryContributor.from_user(user, request) - if user else None - for user in users - ] + return LibraryHistoryContributor.from_user(user, request) if user else None def resolve_change_action(old_version: PublishableEntityVersion | None, new_version: PublishableEntityVersion | None) -> str: diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 5e7cde8a6152..dae586e35ee1 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -8,6 +8,7 @@ import logging import mimetypes from datetime import datetime, timezone +from functools import cache from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -60,13 +61,14 @@ from ..models import ContentLibrary from .block_metadata import ( DirectPublishedEntity, + LibraryHistoryContributor, LibraryHistoryEntry, LibraryPublishHistoryGroup, LibraryXBlockMetadata, LibraryXBlockStaticFile, direct_published_entity_from_record, + make_contributor, resolve_change_action, - resolve_contributors, ) from .collections import library_collection_locator from .container_metadata import container_subclass_for_olx_tag @@ -229,19 +231,19 @@ def get_library_component_draft_history( except ObjectDoesNotExist as exc: raise ContentLibraryBlockNotFound(usage_key) from exc - records = list( + @cache + def _contributor(user): + return make_contributor(user, request) + + draft_change_records = ( content_api.get_entity_draft_history(component.publishable_entity) .select_related("entity__component__component_type", "draft_change_log__changed_by__profile") ) - contributor_list = resolve_contributors( - (record.draft_change_log.changed_by for record in records), request - ) - entries = [] - for record, contributor in zip(records, contributor_list, strict=False): + for record in draft_change_records: version = record.new_version if record.new_version is not None else record.old_version entries.append(LibraryHistoryEntry( - contributor=contributor, + contributor=_contributor(record.draft_change_log.changed_by), changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", item_type=record.entity.component.component_type.name, @@ -294,7 +296,11 @@ def get_library_component_publish_history( old_version_num=old_version_num, new_version_num=new_version_num, ).select_related('profile')) - contributors = [c for c in resolve_contributors(raw_contributors, request) if c is not None] + contributors = [ + LibraryHistoryContributor.from_user(user, request) + for user in raw_contributors + if user is not None + ] if pub_record.direct is None or pub_record.direct is True: # Pre-Verawood or component was directly published: single entry for itself. @@ -355,21 +361,21 @@ def get_library_component_publish_history_entries( except ObjectDoesNotExist as exc: raise ContentLibraryBlockNotFound(usage_key) from exc - records = list( + @cache + def _contributor(user): + return make_contributor(user, request) + + records = ( content_api.get_entity_publish_history_entries( component.publishable_entity, str(publish_log_uuid) ) .select_related("entity__component__component_type", "draft_change_log__changed_by__profile") ) - contributor_list = resolve_contributors( - (record.draft_change_log.changed_by for record in records), request - ) - entries = [] - for record, contributor in zip(records, contributor_list, strict=False): + for record in records: version = record.new_version if record.new_version is not None else record.old_version entries.append(LibraryHistoryEntry( - contributor=contributor, + contributor=_contributor(record.draft_change_log.changed_by), changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", item_type=record.entity.component.component_type.name, @@ -405,9 +411,9 @@ def get_library_component_creation_entry( if first_version is None: return None - contributor_list = resolve_contributors([first_version.created_by], request) + user = first_version.created_by return LibraryHistoryEntry( - contributor=contributor_list[0], + contributor=make_contributor(user, request), changed_at=first_version.created, title=first_version.title, item_type=component.component_type.name, diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 78d865254481..ba3635a98ae4 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -7,6 +7,7 @@ import logging import typing from datetime import datetime, timezone +from functools import cache from uuid import UUID, uuid4 from django.core.exceptions import ObjectDoesNotExist @@ -30,11 +31,12 @@ from .. import tasks from ..models import ContentLibrary from .block_metadata import ( - LibraryXBlockMetadata, + LibraryHistoryContributor, LibraryHistoryEntry, LibraryPublishHistoryGroup, + LibraryXBlockMetadata, direct_published_entity_from_record, - resolve_contributors, + make_contributor, resolve_change_action, ) from .container_metadata import ( @@ -542,20 +544,18 @@ def get_library_container_draft_history( # Collect entity IDs for all components nested inside this container. component_entity_ids = content_api.get_descendant_component_entity_ids(container) + @cache + def _contributor(user): + return make_contributor(user, request) + results: list[LibraryHistoryEntry] = [] # Process the container itself first, then each descendant component. for item_id in [container.pk] + component_entity_ids: - records = content_api.get_entity_draft_history(item_id).select_related( + for record in content_api.get_entity_draft_history(item_id).select_related( "entity__component__component_type", "entity__container__container_type", "draft_change_log__changed_by__profile", - ) - contributor_list = resolve_contributors( - (record.draft_change_log.changed_by for record in records), request - ) - - entries = [] - for record, contributor in zip(records, contributor_list, strict=False): + ): # Use the new version when available; fall back to the old version # (e.g. for delete records where new_version is None). version = record.new_version if record.new_version is not None else record.old_version @@ -563,16 +563,14 @@ def get_library_container_draft_history( item_type = record.entity.component.component_type.name except Component.DoesNotExist: item_type = record.entity.container.container_type.type_code - entries.append(LibraryHistoryEntry( - contributor=contributor, + results.append(LibraryHistoryEntry( + contributor=_contributor(record.draft_change_log.changed_by), changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", item_type=item_type, action=resolve_change_action(record.old_version, record.new_version), )) - results.extend(entries) - # Return all entries sorted newest-first across the container and its children. results.sort( key=lambda entry: entry.changed_at, @@ -678,8 +676,11 @@ def _build_post_verawood_container_group( old_version_num=old_version_num, new_version_num=new_version_num, ).select_related('profile')) - for contributor in resolve_contributors(raw, request): - if contributor is not None and contributor.username not in seen_usernames: + for user in raw: + if user is None: + continue + contributor = LibraryHistoryContributor.from_user(user, request) + if contributor.username not in seen_usernames: seen_usernames.add(contributor.username) all_contributors.append(contributor) @@ -713,7 +714,11 @@ def _build_pre_verawood_container_group( old_version_num=old_version_num, new_version_num=new_version_num, ).select_related('profile')) - contributors = [c for c in resolve_contributors(raw, request) if c is not None] + contributors = [ + LibraryHistoryContributor.from_user(user, request) + for user in raw + if user is not None + ] entity = direct_published_entity_from_record(pub_record, container_key.lib_key) return LibraryPublishHistoryGroup( @@ -772,10 +777,14 @@ def get_library_container_publish_history_entries( if not relevant_entity_ids: return [] + @cache + def _contributor(user): + return make_contributor(user, request) + entries = [] for entity_id in relevant_entity_ids: try: - records = list( + records = ( content_api.get_entity_publish_history_entries(entity_id, str(publish_log_uuid)) .select_related( 'entity__component__component_type', @@ -786,17 +795,14 @@ def get_library_container_publish_history_entries( except ObjectDoesNotExist: continue - contributor_list = resolve_contributors( - (record.draft_change_log.changed_by for record in records), request - ) - for record, contributor in zip(records, contributor_list, strict=False): + for record in records: version = record.new_version if record.new_version is not None else record.old_version try: item_type = record.entity.component.component_type.name except Component.DoesNotExist: item_type = record.entity.container.container_type.type_code entries.append(LibraryHistoryEntry( - contributor=contributor, + contributor=_contributor(record.draft_change_log.changed_by), changed_at=record.draft_change_log.changed_at, title=version.title if version is not None else "", item_type=item_type, @@ -828,9 +834,9 @@ def get_library_container_creation_entry( if first_version is None: return None - contributor_list = resolve_contributors([first_version.created_by], request) + user = first_version.created_by return LibraryHistoryEntry( - contributor=contributor_list[0], + contributor=make_contributor(user, request), changed_at=first_version.created, title=first_version.title, item_type=container.container_type.type_code, From 13e9f85cf0c5c54145c47bc27859365089bac8dd Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 18:32:06 -0500 Subject: [PATCH 29/31] chore: Update version of openedx-core --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 73e9f6effa10..e70563155479 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -65,7 +65,7 @@ numpy<2.0.0 # breaking changes which openedx-core devs want to roll out manually. New patch versions # are OK to accept automatically. # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-core<0.45 +openedx-core<0.46 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 71790d659ff7..bb550e65286c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -840,7 +840,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/kernel.in # xblocks-contrib -openedx-core==0.44.0 +openedx-core==0.45.0 # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 3ad4c94197fd..d88ef2997b51 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1394,7 +1394,7 @@ openedx-calc==5.0.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # xblocks-contrib -openedx-core==0.44.0 +openedx-core==0.45.0 # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index b7f29a68f1df..16cb4a9d7a8e 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1018,7 +1018,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core==0.44.0 +openedx-core==0.45.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 1e14484defc2..efabcd0cdbb1 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1065,7 +1065,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core==0.44.0 +openedx-core==0.45.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt From 345a94df880e50d24092db74a337220f0f309e30 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 18:32:23 -0500 Subject: [PATCH 30/31] fix: Broken lint --- openedx/core/djangoapps/content_libraries/api/containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index ba3635a98ae4..0e73c97b8383 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -16,7 +16,7 @@ from django.utils.text import slugify from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content import api as content_api -from openedx_content.models_api import Container, Unit, Component, PublishLogRecord +from openedx_content.models_api import Component, Container, PublishLogRecord, Unit from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData, LibraryContainerData from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, From 10d16c5f44fefef86458f0c793b6142206200450 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 19:38:09 -0500 Subject: [PATCH 31/31] fix: Broken lint and tests --- .../core/djangoapps/content_libraries/api/block_metadata.py | 5 ++++- .../djangoapps/content_libraries/tests/test_containers.py | 2 +- 2 files changed, 5 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 da794d419986..c6aa1a8ca93d 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -168,7 +168,10 @@ def make_contributor(user, request=None) -> LibraryHistoryContributor | None: return LibraryHistoryContributor.from_user(user, request) if user else None -def resolve_change_action(old_version: PublishableEntityVersion | None, new_version: PublishableEntityVersion | None) -> str: +def resolve_change_action( + old_version: PublishableEntityVersion | None, + new_version: PublishableEntityVersion | None, +) -> str: """ Derive a human-readable action label from a draft history record's versions. """ diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 68eec36cd1a2..65bba4c2ae7b 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -1047,7 +1047,7 @@ def test_container_draft_history_shows_unpublished_edits(self): 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 "contributor" in entry assert "title" in entry assert "action" in entry