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
52 changes: 52 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,9 +3,11 @@
"""
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
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user
from .libraries import (
library_component_usage_key,
PublishableItem,
Expand All @@ -15,6 +17,9 @@
__all__ = [
"LibraryXBlockMetadata",
"LibraryXBlockStaticFile",
"LibraryComponentHistoryEntry",
"LibraryComponentContributor",
"LibraryComponentPublishHistoryGroup",
]


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


@dataclass(frozen=True)
class LibraryComponentHistoryEntry:
"""
One entry in the history of a library component.
"""
changed_by: LibraryComponentContributor | None
changed_at: datetime
title: str # title at time of change
block_type: str
action: str # "edited" | "renamed"


@dataclass(frozen=True)
class LibraryComponentContributor:
"""
A contributor in a publish history group, with profile image URLs.
"""
username: str
profile_image_urls: dict # {"full": str, "large": str, "medium": str, "small": str}

@classmethod
def from_user(cls, user, request=None) -> 'LibraryComponentContributor':
return cls(
username=user.username,
profile_image_urls=get_profile_image_urls_for_user(user, request),
)


@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
title: str # title at time of publish
block_type: str
contributors: list[LibraryComponentContributor] # distinct authors of versions in this group
contributors_count: int


@dataclass(frozen=True)
class LibraryXBlockStaticFile:
"""
Expand Down
164 changes: 162 additions & 2 deletions openedx/core/djangoapps/content_libraries/api/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from uuid import uuid4

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import validate_unicode_slug
from django.db import transaction
Expand Down Expand Up @@ -58,7 +59,13 @@
InvalidNameError,
LibraryBlockAlreadyExists,
)
from .block_metadata import LibraryXBlockMetadata, LibraryXBlockStaticFile
from .block_metadata import (
LibraryComponentContributor,
LibraryComponentHistoryEntry,
LibraryComponentPublishHistoryGroup,
LibraryXBlockMetadata,
LibraryXBlockStaticFile,
)
from .containers import (
create_container,
get_container,
Expand All @@ -78,6 +85,7 @@
from openedx.core.djangoapps.content_staging.api import StagedContentFileData

log = logging.getLogger(__name__)
User = get_user_model()

# The public API is only the following symbols:
__all__ = [
Expand All @@ -98,6 +106,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 +202,156 @@ def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=Fals
return xblock_metadata


def get_library_component_draft_history(
usage_key: LibraryUsageLocatorV2,
request=None,
) -> list[LibraryComponentHistoryEntry]:
"""
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 = list(content_api.get_entity_draft_history(component.publishable_entity))
changed_by_list = _resolve_contributors(
(record.draft_change_log.changed_by for record in records), request
)

entries = []
for record, changed_by in zip(records, changed_by_list):
version = record.new_version if record.new_version is not None else record.old_version
entries.append(LibraryComponentHistoryEntry(
changed_by=changed_by,
changed_at=record.draft_change_log.changed_at,
title=version.title if version is not None else "",
block_type=record.entity.component.component_type.name,
action=_resolve_component_change_action(record.old_version, record.new_version),
))
return entries


def _resolve_contributors(users, request=None) -> list[LibraryComponentContributor | None]:
"""
Convert an iterable of User objects (possibly containing None) to a list of
LibraryComponentContributor.
"""
users_list = list(users)
user_pks = list({user.pk for user in users_list if user is not None})
prefetched = {
user.pk: user
for user in User.objects.filter(pk__in=user_pks).select_related('profile')
} if user_pks else {}
return [
LibraryComponentContributor.from_user(prefetched.get(user.pk, user), request)
if user else None
for user in users_list
]


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,
request=None,
) -> 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

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

contributors = [c for c in _resolve_contributors(raw_contributors, request) if c is not None]

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,
title=pub_record.new_version.title if pub_record.new_version else "",
block_type=pub_record.entity.component.component_type.name,
contributors_count=len(contributors),
))

return groups


def get_library_component_publish_history_entries(
usage_key: LibraryUsageLocatorV2,
publish_log_uuid: str,
request=None,
) -> list[LibraryComponentHistoryEntry]:
"""
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 = list(content_api.get_entity_publish_history_entries(
component.publishable_entity, publish_log_uuid
))
changed_by_list = _resolve_contributors(
(record.draft_change_log.changed_by for record in records), request
)

entries = []
for record, changed_by in zip(records, changed_by_list):
version = record.new_version if record.new_version is not None else record.old_version
entries.append(LibraryComponentHistoryEntry(
changed_by=changed_by,
changed_at=record.draft_change_log.changed_at,
title=version.title if version is not None else "",
block_type=record.entity.component.component_type.name,
action=_resolve_component_change_action(record.old_version, record.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 +843,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.LibraryComponentHistoryEntrySerializer

@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, request=request)
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, request=request)
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.LibraryComponentHistoryEntrySerializer

@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, request=request)
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
Loading
Loading