diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py
index fb1d2235219f..2edd6d5a3aae 100644
--- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py
+++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py
@@ -4,9 +4,15 @@
from __future__ import annotations
from dataclasses import dataclass
+from datetime import datetime
+from uuid import UUID
+from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext as _ # noqa: F401
-from opaque_keys.edx.locator import LibraryUsageLocatorV2
+from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
+from openedx_content.models_api import PublishableEntityVersion, PublishLogRecord
+
+from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user
from .libraries import PublishableItem, library_component_usage_key
@@ -14,9 +20,11 @@
__all__ = [
"LibraryXBlockMetadata",
"LibraryXBlockStaticFile",
+ "LibraryHistoryEntry",
+ "LibraryHistoryContributor",
+ "LibraryPublishHistoryGroup",
]
-
@dataclass(frozen=True, kw_only=True)
class LibraryXBlockMetadata(PublishableItem):
"""
@@ -48,6 +56,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,
@@ -63,6 +72,77 @@ def from_component(cls, library_key, component, associated_collections=None):
)
+@dataclass(frozen=True)
+class LibraryHistoryEntry:
+ """
+ One entry in the history of a library component.
+ """
+ changed_by: LibraryHistoryContributor | None
+ changed_at: datetime
+ title: str # title at time of change
+ item_type: str
+ action: str # "created" | "edited" | "renamed" | "deleted"
+
+
+@dataclass(frozen=True)
+class LibraryHistoryContributor:
+ """
+ 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) -> LibraryHistoryContributor:
+ return cls(
+ username=user.username,
+ profile_image_urls=get_profile_image_urls_for_user(user, request),
+ )
+
+
+@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: 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
+
+
+@dataclass(frozen=True)
+class LibraryPublishHistoryGroup:
+ """
+ Summary of a publish event for a library item.
+
+ 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: UUID
+ published_by: AbstractUser | None
+ published_at: datetime
+ contributors: list[LibraryHistoryContributor] # distinct authors of versions in this group
+ contributors_count: int
+ # 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): usage_key.
+ scope_entity_key: LibraryUsageLocatorV2 | LibraryContainerLocator | None
+
+
@dataclass(frozen=True)
class LibraryXBlockStaticFile:
"""
@@ -76,3 +156,86 @@ 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.
+
+ 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).
+
+ 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.
+ """
+ return [
+ LibraryHistoryContributor.from_user(user, request)
+ if user else None
+ for user in users
+ ]
+
+
+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.
+ """
+ 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"
+
+
+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',
+ 'old_version',
+ )
+ """
+ # Import here to avoid circular imports (container_metadata imports block_metadata).
+ from .container_metadata import library_container_locator # noqa: PLC0415
+
+ # 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 ""
+ if hasattr(record.entity, 'component'):
+ component = record.entity.component
+ return DirectPublishedEntity(
+ 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,
+ )
+ 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")
diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py
index f8d45b78d5fe..c1c1aad91b9b 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
@@ -24,7 +24,14 @@
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,
+)
from openedx_events.content_authoring.data import (
ContentObjectChangedData,
LibraryBlockData,
@@ -51,7 +58,16 @@
from .. import tasks
from ..models import ContentLibrary
-from .block_metadata import LibraryXBlockMetadata, LibraryXBlockStaticFile
+from .block_metadata import (
+ DirectPublishedEntity,
+ LibraryHistoryEntry,
+ LibraryPublishHistoryGroup,
+ LibraryXBlockMetadata,
+ LibraryXBlockStaticFile,
+ 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 (
@@ -78,6 +94,7 @@
log = logging.getLogger(__name__)
+
# The public API is only the following symbols:
__all__ = [
# API methods
@@ -97,6 +114,10 @@
"add_library_block_static_asset_file",
"delete_library_block_static_asset_file",
"publish_component_changes",
+ "get_library_component_draft_history",
+ "get_library_component_publish_history",
+ "get_library_component_publish_history_entries",
+ "get_library_component_creation_entry",
]
@@ -193,6 +214,207 @@ def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=Fals
return xblock_metadata
+def get_library_component_draft_history(
+ usage_key: LibraryUsageLocatorV2,
+ request=None,
+) -> list[LibraryHistoryEntry]:
+ """
+ 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 = list(
+ 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(
+ (record.draft_change_log.changed_by for record in records), request
+ )
+
+ entries = []
+ 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,
+ 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,
+ action=resolve_change_action(record.old_version, record.new_version),
+ ))
+ return entries
+
+
+def get_library_component_publish_history(
+ usage_key: LibraryUsageLocatorV2,
+ request=None,
+) -> list[LibraryPublishHistoryGroup]:
+ """
+ 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)
+
+ 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.
+ """
+ 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)
+ .select_related("entity__component__component_type")
+ )
+
+ 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
+
+ raw_contributors = list(content_api.get_entity_version_contributors(
+ 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:
+ # Pre-Verawood or component was directly published: single entry for itself.
+ direct_published_entities = [DirectPublishedEntity(
+ 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,
+ )]
+ 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',
+ 'old_version',
+ )
+ )
+ direct_published_entities = [
+ direct_published_entity_from_record(r, usage_key.lib_key)
+ for r in direct_records
+ ] or [DirectPublishedEntity(
+ 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,
+ )]
+
+ groups.append(LibraryPublishHistoryGroup(
+ 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,
+ contributors_count=len(contributors),
+ direct_published_entities=direct_published_entities,
+ scope_entity_key=usage_key,
+ ))
+
+ return groups
+
+
+def get_library_component_publish_history_entries(
+ usage_key: LibraryUsageLocatorV2,
+ publish_log_uuid: UUID,
+ request=None,
+) -> list[LibraryHistoryEntry]:
+ """
+ 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 = list(
+ 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")
+ )
+ 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, strict=False):
+ 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 "",
+ item_type=record.entity.component.component_type.name,
+ action=resolve_change_action(record.old_version, record.new_version),
+ ))
+ return entries
+
+
+def get_library_component_creation_entry(
+ usage_key: LibraryUsageLocatorV2,
+ request=None,
+) -> LibraryHistoryEntry | None:
+ """
+ Return the creation entry for a library component.
+
+ 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.
+
+ 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__profile")
+ .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=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.
@@ -685,7 +907,6 @@ def import_staged_content_from_user_clipboard(library_key: LibraryLocatorV2, use
now,
)
-
def get_or_create_olx_media_type(block_type: str) -> MediaType:
"""
Get or create a MediaType for the block type.
diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py
index 0b126e5665df..d6ccc06cf851 100644
--- a/openedx/core/djangoapps/content_libraries/api/containers.py
+++ b/openedx/core/djangoapps/content_libraries/api/containers.py
@@ -7,14 +7,15 @@
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
from django.db.models import F
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, PublishLogRecord
from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData, LibraryContainerData
from openedx_events.content_authoring.signals import (
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
@@ -28,7 +29,14 @@
from .. import tasks
from ..models import ContentLibrary
-from .block_metadata import LibraryXBlockMetadata
+from .block_metadata import (
+ LibraryXBlockMetadata,
+ LibraryHistoryEntry,
+ LibraryPublishHistoryGroup,
+ direct_published_entity_from_record,
+ resolve_contributors,
+ resolve_change_action,
+)
from .container_metadata import (
LIBRARY_ALLOWED_CONTAINER_TYPES,
ContainerHierarchy,
@@ -59,6 +67,10 @@
"get_library_object_hierarchy",
"copy_container",
"library_container_locator",
+ "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__)
@@ -513,3 +525,314 @@ 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).select_related(
+ "entity__component__component_type",
+ "entity__container__container_type",
+ "draft_change_log__changed_by__profile",
+ )
+ 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, 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
+ 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),
+ ))
+
+ 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
+
+
+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.
+
+ Pre-Verawood records (direct=None): one group per entity × publish event
+ (same PublishLog may produce multiple groups — one per entity in scope).
+
+ 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
+
+ # Collect all records grouped by publish_log_uuid.
+ 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",
+ "new_version",
+ "old_version",
+ ):
+ uuid: UUID = pub_record.publish_log.uuid
+ publish_log_groups.setdefault(uuid, []).append((entity_id, pub_record))
+
+ 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 _build_post_verawood_container_group(
+ uuid: UUID,
+ 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',
+ 'old_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,
+ ).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)
+ 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,
+ ).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)
+ return LibraryPublishHistoryGroup(
+ 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,
+ 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: UUID,
+ request=None,
+) -> list[LibraryHistoryEntry]:
+ """
+ [ 🛑 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).
+
+ 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(scope_entity_key)
+ component_entity_ids = content_api.get_descendant_component_entity_ids(container)
+ scope_entity_ids = {container.pk} | set(component_entity_ids)
+
+ publish_log_records = PublishLogRecord.objects.filter(publish_log__uuid=publish_log_uuid)
+ is_post_verawood = publish_log_records.filter(direct__isnull=False).exists()
+
+ 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 entity_id in relevant_entity_ids:
+ try:
+ records = list(
+ content_api.get_entity_publish_history_entries(entity_id, str(publish_log_uuid))
+ .select_related(
+ 'entity__component__component_type',
+ 'entity__container__container_type',
+ 'draft_change_log__changed_by__profile',
+ )
+ )
+ 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, 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,
+ 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
+
+
+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__profile")
+ .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/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py
index 68d4258bb065..1376adec8fa1 100644
--- a/openedx/core/djangoapps/content_libraries/api/libraries.py
+++ b/openedx/core/djangoapps/content_libraries/api/libraries.py
@@ -212,6 +212,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 8dd70a069edb..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
@@ -8,7 +10,8 @@
from django.http import Http404, HttpResponse, StreamingHttpResponse
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
@@ -141,6 +144,120 @@ 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.LibraryHistoryEntrySerializer
+
+ @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, request=request)
+ 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.LibraryPublishHistoryGroupSerializer
+
+ @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, request=request)
+ return Response(self.serializer_class(history, many=True).data)
+
+
+@method_decorator(non_atomic_requests, name="dispatch")
+@view_auth_classes()
+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
+ 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
+
+ @convert_exceptions
+ def get(self, request, lib_key_str):
+ """
+ Get the draft change entries for a specific publish event, ordered most-recent-first.
+
+ Query parameters:
+ - 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)
+ scope_entity_key_str = request.query_params.get("scope_entity_key", "")
+ 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)
+ 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(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 scope_entity_key: {scope_entity_key_str!r}"}, status=400)
+
+ 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.LibraryHistoryEntrySerializer
+
+ @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/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py
index 12a4132920c3..6a63a723ed3a 100644
--- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py
+++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py
@@ -17,6 +17,7 @@
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
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
@@ -436,3 +437,76 @@ 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)
+
+
+@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)
+
+
+@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/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py
index f8dd8b18a839..e30d07b02401 100644
--- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py
+++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py
@@ -147,6 +147,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
@@ -176,6 +177,97 @@ class LibraryXBlockMetadataSerializer(PublishableItemSerializer):
block_type = serializers.CharField(source="usage_key.block_type")
+class LibraryHistoryContributorSerializer(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 LibraryHistoryEntrySerializer(serializers.Serializer):
+ """
+ Serializer for a single entry in the history of a library item.
+ """
+ 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)
+ item_type = serializers.CharField(read_only=True)
+ 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 = OpaqueKeySerializer(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.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)
+ contributors_count = serializers.IntegerField(read_only=True)
+ direct_published_entities = DirectPublishedEntitySerializer(many=True, read_only=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
+
+
class LibraryXBlockTypeSerializer(serializers.Serializer):
"""
Serializer for LibraryXBlockType
@@ -302,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.
diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py
index 1ccd076ad3c1..4137ee36d045 100644
--- a/openedx/core/djangoapps/content_libraries/tests/base.py
+++ b/openedx/core/djangoapps/content_libraries/tests/base.py
@@ -39,6 +39,10 @@
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_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
URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file
@@ -50,6 +54,9 @@
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_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
@@ -323,6 +330,48 @@ 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_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'?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):
+ """ 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_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)
+
+ 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)
@@ -509,6 +558,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 a95b238b78a4..68eec36cd1a2 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"
@@ -1013,3 +1013,113 @@ 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.
+ """
+ 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=UTC)):
+ self._publish_container(unit["id"])
+
+ 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=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.
+ """
+ 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=UTC)):
+ self._publish_container(unit["id"])
+
+ 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=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/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
index 72655473dd3d..e5fecf81d507 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,11 +318,11 @@ 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")
+ 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,
@@ -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=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_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.
+ """
+ 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):
+ """
+ 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")
+ block_key = block["id"]
+
+ publish_time = datetime(2026, 1, 10, 12, 0, 0, tzinfo=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 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_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.
+ """
+ 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=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=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=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 any(c["username"] == self.user.username for c 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=UTC)):
+ self._set_library_block_olx(block_key, "edit 1
")
+ 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=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 an empty list.
+ """
+ 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=UTC)):
+ self._publish_library_block(block_key)
+
+ fake_uuid = str(uuid.uuid4())
+ entries = self._get_block_publish_history_entries(block_key, fake_uuid, expect_response=200)
+ assert entries == []
+
+ 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=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)
+
+ # --- 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=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=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,
+ 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["item_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)
+
+ 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", display_name="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", display_name="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", display_name="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 1cbcc23610b6..8fabe7d96cca 100644
--- a/openedx/core/djangoapps/content_libraries/urls.py
+++ b/openedx/core/djangoapps/content_libraries/urls.py
@@ -46,6 +46,8 @@
path('team/user//', libraries.LibraryTeamUserView.as_view()),
# Add/Edit (PUT) or remove (DELETE) a group's permission to use this library
path('team/group//', libraries.LibraryTeamGroupView.as_view()),
+ # 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
@@ -68,6 +70,12 @@
path('assets/', blocks.LibraryBlockAssetListView.as_view()),
path('assets/', blocks.LibraryBlockAssetView.as_view()),
path('publish/', blocks.LibraryBlockPublishView.as_view()),
+ # Get the draft change history for this block
+ path('draft_history/', blocks.LibraryComponentDraftHistoryView.as_view()),
+ # Get the publish history for this block (list of publish events)
+ path('publish_history/', blocks.LibraryComponentPublishHistoryView.as_view()),
+ # Get the 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
@@ -85,6 +93,12 @@
# 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()),
+ # 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([
diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in
index d285ad77f902..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
-openedx-core # Open edX Core: Content, Tagging, and other foundational APIs
+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)