-
Notifications
You must be signed in to change notification settings - Fork 4.3k
feat: History log logic for Components and Containers [FC-0123] #38178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
|
@@ -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, | ||
|
|
@@ -63,6 +85,76 @@ def from_component(cls, library_key, component, associated_collections=None): | |
| ) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class LibraryHistoryEntry: | ||
| """ | ||
| 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: | ||
| """ | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both
LibraryHistoryEntryandLibraryHistoryContributorare used across different scopes, including both draft history and publish history. They are general types:LibraryHistoryEntryrepresents an individual entry in any scope, andLibraryHistoryContributorrepresents an individual contributor in any scope.