Skip to content

Commit c49b1b8

Browse files
ChrisChVmarslanabdulrauf
authored andcommitted
feat: History log logic for Components and Containers (openedx#38178)
**Publish history groups: Pre-Verawood vs Post-Verawood** The `openedx-content` library added a `direct` field to `PublishLogRecord` starting in the Verawood release (openedx/openedx-core#539). This field changes how publish history groups are structured, so the endpoint handles both eras: **Pre-Verawood** (`PublishLogRecord.direct is None`): When a container and its components are published together, each entity produces its own independent group, even though they share the same `publish_log_uuid`. For example, publishing a Unit with 3 components creates 4 separate groups. Each group has `scope_entity_key` set to that specific entity's key, which the frontend must pass to the entries endpoint to fetch that entity's individual changes. **Post-Verawood** (`PublishLogRecord.direct is not None`): The `direct` field marks which entities the user explicitly clicked "Publish" on (`direct=True`) vs. which were pulled in as side effects (`direct=False`, e.g. a shared component published from a sibling container). In this era, all entities from the same `PublishLog` are merged into a single group, and `direct_published_entities` lists only the explicitly published items. The `scope_entity_key` is `null` — the frontend uses the current container key to fetch entries. This design means the frontend does not need era awareness: it always uses `group.scope_entity_key ?? currentContainerKey` when calling the entries endpoint. **Functions** - Implements python api and REST_API functions to get the history log for a component: - `get_library_component_draft_history`: Return the draft change history for a library component since its last publication. - `get_library_component_publish_history`: Return the publish history of a library component as a list of groups. - `get_library_component_publish_history_entries`: Return the individual draft change entries for a specific publish event. - `get_library_component_creation_entry`: Return the creation entry (who created it and when). - Implements python api and REST_API functions to get the history log for containers: - `get_library_container_draft_history`: Return the combined draft history for a container and all of its descendant components. - `get_library_container_publish_history`: Return the publish history of a container as a list of groups. - `get_library_container_publish_history_entries`: Return the individual draft change entries for the container entity in a specific publish event. - `get_library_container_creation_entry`: Return the creation entry for a container. Note: This used Claude's help to write the separation of the Post and Pre-Verawood eras
1 parent 49b9aec commit c49b1b8

12 files changed

Lines changed: 1845 additions & 72 deletions

File tree

openedx/core/djangoapps/content_libraries/api/block_metadata.py

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,39 @@
44
from __future__ import annotations
55

66
from dataclasses import dataclass
7+
from datetime import datetime
8+
from typing import TypedDict
9+
from uuid import UUID
710

11+
from django.contrib.auth.models import AbstractUser
812
from django.utils.translation import gettext as _ # noqa: F401
9-
from opaque_keys.edx.locator import LibraryUsageLocatorV2
13+
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
14+
from openedx_content.models_api import PublishableEntityVersion, PublishLogRecord
1015

11-
from .libraries import PublishableItem, library_component_usage_key
16+
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user
17+
18+
from .libraries import (
19+
PublishableItem,
20+
library_component_usage_key,
21+
)
1222

1323
# The public API is only the following symbols:
1424
__all__ = [
1525
"LibraryXBlockMetadata",
1626
"LibraryXBlockStaticFile",
27+
"LibraryHistoryEntry",
28+
"LibraryHistoryContributor",
29+
"LibraryPublishHistoryGroup",
1730
]
1831

32+
class ProfileImageUrls(TypedDict):
33+
"""URLs for a user's profile image in different sizes."""
34+
35+
full: str
36+
large: str
37+
medium: str
38+
small: str
39+
1940

2041
@dataclass(frozen=True, kw_only=True)
2142
class LibraryXBlockMetadata(PublishableItem):
@@ -48,6 +69,7 @@ def from_component(cls, library_key, component, associated_collections=None):
4869
usage_key=usage_key,
4970
display_name=draft.title,
5071
created=component.created,
72+
created_by=component.created_by.username if component.created_by else None,
5173
modified=draft.created,
5274
draft_version_num=draft.version_num,
5375
published_version_num=published.version_num if published else None,
@@ -63,6 +85,76 @@ def from_component(cls, library_key, component, associated_collections=None):
6385
)
6486

6587

88+
@dataclass(frozen=True)
89+
class LibraryHistoryEntry:
90+
"""
91+
One entry in the history of a library component.
92+
"""
93+
contributor: LibraryHistoryContributor | None
94+
changed_at: datetime
95+
title: str # title at time of change
96+
item_type: str
97+
action: str # "created" | "edited" | "renamed" | "deleted"
98+
99+
100+
@dataclass(frozen=True)
101+
class LibraryHistoryContributor:
102+
"""
103+
A contributor in a publish history group, with profile image URLs.
104+
"""
105+
username: str
106+
profile_image_urls: ProfileImageUrls
107+
108+
@classmethod
109+
def from_user(cls, user, request=None) -> LibraryHistoryContributor:
110+
return cls(
111+
username=user.username,
112+
profile_image_urls=get_profile_image_urls_for_user(user, request),
113+
)
114+
115+
116+
@dataclass(frozen=True)
117+
class DirectPublishedEntity:
118+
"""
119+
Represents one entity the user directly requested to publish (direct=True).
120+
Each entry carries its own title and entity_type so the frontend can display
121+
the correct label for each directly published item.
122+
123+
Pre-Verawood groups have exactly one entry (approximated from available data).
124+
Post-Verawood groups have one entry per direct=True record in the PublishLog.
125+
"""
126+
entity_key: LibraryUsageLocatorV2 | LibraryContainerLocator
127+
title: str # title of the entity at time of publish
128+
entity_type: str # e.g. "html", "problem" for components; "unit", "section" for containers
129+
130+
131+
@dataclass(frozen=True)
132+
class LibraryPublishHistoryGroup:
133+
"""
134+
Summary of a publish event for a library item.
135+
136+
Each instance represents one or more PublishLogRecords, and includes the
137+
set of contributors who authored draft changes between the previous publish
138+
and this one.
139+
140+
Pre-Verawood (direct=None): one group per entity × publish event.
141+
Post-Verawood (direct!=None): one group per unique PublishLog.
142+
"""
143+
publish_log_uuid: UUID
144+
published_by: AbstractUser | None
145+
published_at: datetime
146+
contributors: list[LibraryHistoryContributor] # distinct authors of versions in this group
147+
# Each element is one entity the user directly requested to publish.
148+
# Pre-Verawood: single approximated entry derived from the group's entity.
149+
# Post-Verawood: one entry per direct=True record in the PublishLog.
150+
direct_published_entities: list[DirectPublishedEntity]
151+
# Key to pass as scope_entity_key when fetching entries for this group.
152+
# Pre-Verawood: the specific entity key for this group (container or usage key).
153+
# Post-Verawood container groups: None — frontend must use currentContainerKey.
154+
# Component history (all eras): usage_key.
155+
scope_entity_key: LibraryUsageLocatorV2 | LibraryContainerLocator | None
156+
157+
66158
@dataclass(frozen=True)
67159
class LibraryXBlockStaticFile:
68160
"""
@@ -76,3 +168,85 @@ class LibraryXBlockStaticFile:
76168
url: str
77169
# Size in bytes
78170
size: int
171+
172+
173+
174+
def get_entity_item_type(entity) -> str:
175+
"""
176+
Return the item type string for a PublishableEntity (component or container).
177+
"""
178+
if hasattr(entity, 'component'):
179+
return entity.component.component_type.name
180+
if hasattr(entity, 'container'):
181+
return entity.container.container_type.type_code
182+
raise ValueError(f"Entity {entity} is neither a component nor a container.")
183+
184+
185+
def make_contributor(user, request=None) -> LibraryHistoryContributor | None:
186+
"""
187+
Convert a single User (or None) to a LibraryHistoryContributor.
188+
189+
None input produces None output — frontend renders as default/anonymous.
190+
"""
191+
return LibraryHistoryContributor.from_user(user, request) if user else None
192+
193+
194+
def resolve_change_action(
195+
old_version: PublishableEntityVersion | None,
196+
new_version: PublishableEntityVersion | None,
197+
) -> str:
198+
"""
199+
Derive a human-readable action label from a draft history record's versions.
200+
"""
201+
if old_version is None:
202+
return "created"
203+
if new_version is None:
204+
return "deleted"
205+
if old_version.title != new_version.title:
206+
return "renamed"
207+
return "edited"
208+
209+
210+
def direct_published_entity_from_record(
211+
record: PublishLogRecord,
212+
lib_key: LibraryLocatorV2,
213+
) -> DirectPublishedEntity:
214+
"""
215+
Build a DirectPublishedEntity from a PublishLogRecord.
216+
217+
lib_key is used only to construct locator strings — entity_key is always
218+
derived from record.entity itself, never from an external container key.
219+
220+
Callers must ensure the record is fetched with:
221+
select_related(
222+
'entity__component__component_type',
223+
'entity__container__container_type',
224+
'new_version',
225+
'old_version',
226+
)
227+
"""
228+
# Import here to avoid circular imports (container_metadata imports block_metadata).
229+
from .container_metadata import library_container_locator # noqa: PLC0415
230+
231+
# Use new_version title when available; fall back to old_version for soft-deletes (new_version=None).
232+
version = record.new_version or record.old_version
233+
title = version.title if version else ""
234+
if hasattr(record.entity, 'component'):
235+
component = record.entity.component
236+
return DirectPublishedEntity(
237+
entity_key=LibraryUsageLocatorV2( # type: ignore[abstract]
238+
lib_key=lib_key,
239+
block_type=component.component_type.name,
240+
usage_id=component.component_code,
241+
),
242+
title=title,
243+
entity_type=component.component_type.name,
244+
)
245+
if hasattr(record.entity, 'container'):
246+
container = record.entity.container
247+
return DirectPublishedEntity(
248+
entity_key=library_container_locator(lib_key, container),
249+
title=title,
250+
entity_type=container.container_type.type_code,
251+
)
252+
raise ValueError(f"PublishableEntity {record.entity.pk!r} is neither a Component nor a Container")

0 commit comments

Comments
 (0)