Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 176 additions & 2 deletions openedx/core/djangoapps/content_libraries/api/block_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,39 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from typing import TypedDict
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 .libraries import PublishableItem, library_component_usage_key
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user

from .libraries import (
PublishableItem,
library_component_usage_key,
)

# The public API is only the following symbols:
__all__ = [
"LibraryXBlockMetadata",
"LibraryXBlockStaticFile",
"LibraryHistoryEntry",
"LibraryHistoryContributor",
"LibraryPublishHistoryGroup",
]

class ProfileImageUrls(TypedDict):
"""URLs for a user's profile image in different sizes."""

full: str
large: str
medium: str
small: str


@dataclass(frozen=True, kw_only=True)
class LibraryXBlockMetadata(PublishableItem):
Expand Down Expand Up @@ -48,6 +69,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,
Expand All @@ -63,6 +85,76 @@ def from_component(cls, library_key, component, associated_collections=None):
)


@dataclass(frozen=True)
class LibraryHistoryEntry:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is specifically scoped to the Draft History, let's rename it (and LibraryHistoryContributor) to reflect that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both LibraryHistoryEntry and LibraryHistoryContributor are used across different scopes, including both draft history and publish history. They are general types: LibraryHistoryEntry represents an individual entry in any scope, and LibraryHistoryContributor represents an individual contributor in any scope.

"""
One entry in the history of a library component.
"""
contributor: 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: ProfileImageUrls

@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
# Each element is one entity the user directly requested to publish.
# Pre-Verawood: single approximated entry derived from the group's entity.
# 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:
"""
Expand All @@ -76,3 +168,85 @@ class LibraryXBlockStaticFile:
url: str
# Size in bytes
size: int



def get_entity_item_type(entity) -> str:
"""
Return the item type string for a PublishableEntity (component or container).
"""
if hasattr(entity, 'component'):
return entity.component.component_type.name
if hasattr(entity, 'container'):
return entity.container.container_type.type_code
raise ValueError(f"Entity {entity} is neither a component nor a container.")


def make_contributor(user, request=None) -> LibraryHistoryContributor | None:
"""
Convert a single User (or None) to a LibraryHistoryContributor.

None input produces None output — frontend renders as default/anonymous.
"""
return LibraryHistoryContributor.from_user(user, request) if user else None


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"
Comment on lines +205 to +207
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack. Per @sdaitzman, edits takes precedence over renames in terms of display, i.e. if something is both edited and renamed at the same time, we're supposed to say "edited". But the publishing applet has no way to actually tell if a change in versions was just a rename or actually edit + rename, does it?

I suppose this means that we'll eventually (post-Verawood) want to add yet another field to the change log record that enumerates the kind of change it is, so that we can have better clarity there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, there's no way to distinguish a pure rename from a rename + edit with the current data model. We can track this as a follow-up



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")
Loading
Loading