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)