Skip to content

Commit 9142f4e

Browse files
committed
**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 7694a68 commit 9142f4e

12 files changed

Lines changed: 1844 additions & 72 deletions

File tree

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

Lines changed: 175 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,38 @@
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+
from .libraries import (
18+
library_component_usage_key,
19+
PublishableItem,
20+
)
1221

1322
# The public API is only the following symbols:
1423
__all__ = [
1524
"LibraryXBlockMetadata",
1625
"LibraryXBlockStaticFile",
26+
"LibraryHistoryEntry",
27+
"LibraryHistoryContributor",
28+
"LibraryPublishHistoryGroup",
1729
]
1830

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

2040
@dataclass(frozen=True, kw_only=True)
2141
class LibraryXBlockMetadata(PublishableItem):
@@ -48,6 +68,7 @@ def from_component(cls, library_key, component, associated_collections=None):
4868
usage_key=usage_key,
4969
display_name=draft.title,
5070
created=component.created,
71+
created_by=component.created_by.username if component.created_by else None,
5172
modified=draft.created,
5273
draft_version_num=draft.version_num,
5374
published_version_num=published.version_num if published else None,
@@ -63,6 +84,76 @@ def from_component(cls, library_key, component, associated_collections=None):
6384
)
6485

6586

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

0 commit comments

Comments
 (0)