Skip to content
Draft
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
31 changes: 31 additions & 0 deletions openedx/core/djangoapps/content_libraries/api/block_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime

from django.utils.translation import gettext as _
from opaque_keys.edx.locator import LibraryUsageLocatorV2
Expand All @@ -15,6 +16,8 @@
__all__ = [
"LibraryXBlockMetadata",
"LibraryXBlockStaticFile",
"LibraryComponentDraftHistoryEntry",
"LibraryComponentPublishHistoryGroup",
]


Expand Down Expand Up @@ -49,6 +52,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 @@ -64,6 +68,33 @@ def from_component(cls, library_key, component, associated_collections=None):
)


@dataclass(frozen=True)
class LibraryComponentDraftHistoryEntry:
"""
One entry in the draft change history of a library component.
"""
changed_by: object # AUTH_USER_MODEL instance or None
changed_at: datetime
title: str # title at time of change
action: str # "edited" | "renamed"


@dataclass(frozen=True)
class LibraryComponentPublishHistoryGroup:
"""
Summary of a publish event for a library component.

Each instance represents one PublishLogRecord for the component, and
includes the set of contributors who authored draft changes between the
previous publish and this one.
"""
publish_log_uuid: str
published_by: object # AUTH_USER_MODEL instance or None
published_at: datetime
contributors: list # list of AUTH_USER_MODEL, distinct authors of versions in this group
contributors_count: int


@dataclass(frozen=True)
class LibraryXBlockStaticFile:
"""
Expand Down
125 changes: 123 additions & 2 deletions openedx/core/djangoapps/content_libraries/api/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@
InvalidNameError,
LibraryBlockAlreadyExists,
)
from .block_metadata import LibraryXBlockMetadata, LibraryXBlockStaticFile
from .block_metadata import (
LibraryComponentDraftHistoryEntry,
LibraryComponentPublishHistoryGroup,
LibraryXBlockMetadata,
LibraryXBlockStaticFile,
)
from .containers import (
create_container,
get_container,
Expand Down Expand Up @@ -98,6 +103,9 @@
"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",
]


Expand Down Expand Up @@ -191,6 +199,120 @@ def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=Fals
return xblock_metadata


def get_library_component_draft_history(usage_key: LibraryUsageLocatorV2) -> list[LibraryComponentDraftHistoryEntry]:
"""
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 = content_api.get_entity_draft_history(component.publishable_entity)

entries = []
for record in records:
version = record.new_version if record.new_version is not None else record.old_version
entries.append(LibraryComponentDraftHistoryEntry(
changed_by=record.draft_change_log.changed_by,
changed_at=record.draft_change_log.changed_at,
title=version.title if version is not None else "",
action=_resolve_component_change_action(record.old_version, record.new_version),
))
return entries


def _resolve_component_change_action(old_version, new_version) -> str:
if old_version and new_version and old_version.title != new_version.title:
return "renamed"
return "edited"


def get_library_component_publish_history(
usage_key: LibraryUsageLocatorV2,
) -> list[LibraryComponentPublishHistoryGroup]:
"""
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)

Groups are ordered most-recent-first. Returns [] if the component has never
been published.

Contributors are resolved using version bounds (old_version_num → new_version_num)
rather than timestamps to avoid clock-skew issues. old_version_num defaults to
0 for the very first publish. new_version_num is None for soft-delete publishes
(no PublishableEntityVersion is created on soft delete).
"""
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))

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

contributors = list(content_api.get_entity_version_contributors(
entity,
old_version_num=old_version_num,
new_version_num=new_version_num,
))

groups.append(LibraryComponentPublishHistoryGroup(
publish_log_uuid=str(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),
))

return groups


def get_library_component_publish_history_entries(
usage_key: LibraryUsageLocatorV2,
publish_log_uuid: str,
) -> list[LibraryComponentDraftHistoryEntry]:
"""
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 = content_api.get_entity_publish_history_entries(
component.publishable_entity, publish_log_uuid
)
entries = []
for r in records:
version = r.new_version if r.new_version is not None else r.old_version
entries.append(LibraryComponentDraftHistoryEntry(
changed_by=r.draft_change_log.changed_by,
changed_at=r.draft_change_log.changed_at,
title=version.title if version is not None else "",
action=_resolve_component_change_action(r.old_version, r.new_version),
))
return entries


def set_library_block_olx(usage_key: LibraryUsageLocatorV2, new_olx_str: str) -> ComponentVersion:
"""
Replace the OLX source of the given XBlock.
Expand Down Expand Up @@ -682,7 +804,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.
Expand Down
1 change: 1 addition & 0 deletions openedx/core/djangoapps/content_libraries/api/libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,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)
Expand Down
60 changes: 60 additions & 0 deletions openedx/core/djangoapps/content_libraries/rest_api/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,66 @@ 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.LibraryComponentDraftHistoryEntrySerializer

@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)
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.LibraryComponentPublishHistoryGroupSerializer

@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)
return Response(self.serializer_class(history, many=True).data)


@method_decorator(non_atomic_requests, name="dispatch")
@view_auth_classes()
class LibraryComponentPublishHistoryEntriesView(APIView):
"""
View to get the individual draft change entries for a specific publish event.
"""
serializer_class = serializers.LibraryComponentDraftHistoryEntrySerializer

@convert_exceptions
def get(self, request, usage_key_str, publish_log_uuid):
"""
Get the draft change entries for a specific publish event, 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)
try:
entries = api.get_library_component_publish_history_entries(key, publish_log_uuid)
except ObjectDoesNotExist as exc:
raise NotFound(f"No publish event '{publish_log_uuid}' found for this component.") from exc
return Response(self.serializer_class(entries, many=True).data)


@method_decorator(non_atomic_requests, name="dispatch")
@view_auth_classes()
class LibraryBlockAssetListView(APIView):
Expand Down
33 changes: 33 additions & 0 deletions openedx/core/djangoapps/content_libraries/rest_api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,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
Expand Down Expand Up @@ -180,6 +181,38 @@ class LibraryXBlockMetadataSerializer(PublishableItemSerializer):
block_type = serializers.CharField(source="usage_key.block_type")


class LibraryComponentDraftHistoryEntrySerializer(serializers.Serializer):
"""
Serializer for a single entry in the draft history of a library component.
"""
changed_by = serializers.SerializerMethodField()
changed_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
title = serializers.CharField()
action = serializers.CharField()

def get_changed_by(self, obj) -> str | None:
if obj.changed_by is None:
return None
return obj.changed_by.username


class LibraryComponentPublishHistoryGroupSerializer(serializers.Serializer):
"""
Serializer for a publish event summary in the publish history of a library component.
"""
publish_log_uuid = serializers.CharField(read_only=True)
published_by = serializers.SerializerMethodField()
published_at = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
contributors = serializers.SerializerMethodField()
contributors_count = serializers.IntegerField(read_only=True)

def get_published_by(self, obj) -> str | None:
return obj.published_by.username if obj.published_by else None

def get_contributors(self, obj) -> list[str]:
return [u.username for u in obj.contributors]


class LibraryXBlockTypeSerializer(serializers.Serializer):
"""
Serializer for LibraryXBlockType
Expand Down
19 changes: 19 additions & 0 deletions openedx/core/djangoapps/content_libraries/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
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_BLOCK_PUBLISH_HISTORY_ENTRIES = URL_LIB_BLOCK_PUBLISH_HISTORY + '{publish_log_uuid}/entries/'
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
Expand Down Expand Up @@ -321,6 +324,22 @@ 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_block_publish_history_entries(self, block_key, publish_log_uuid, expect_response=200):
""" Get the draft change entries for a specific publish event """
url = URL_LIB_BLOCK_PUBLISH_HISTORY_ENTRIES.format(
block_key=block_key,
publish_log_uuid=publish_log_uuid,
)
return self._api('get', url, 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)
Expand Down
Loading
Loading