Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f2c339f
test: Test for publish section/subsection
ChrisChV May 29, 2025
f1f4043
Merge remote-tracking branch 'origin/master' into chris/FAL-4180-sect…
pomegranited Jun 17, 2025
b8071aa
test: published_by is now None for unpublished containers
pomegranited Jun 17, 2025
7bf61d3
test: adds TODO comments to the tests
pomegranited Jun 17, 2025
c19a64c
feat: adds api to retrieve library block/container hierarchy
pomegranited Jun 23, 2025
b9020f3
test: adds query counts for hierarchy API tests
pomegranited Jun 24, 2025
6a65d5f
Merge remote-tracking branch 'origin/master' into chris/FAL-4180-sect…
pomegranited Jun 26, 2025
6fd8dca
perf: reduce hierarchy API query counts
pomegranited Jun 26, 2025
9923d1e
Merge remote-tracking branch 'origin/master' into chris/FAL-4180-sect…
pomegranited Jun 29, 2025
6fedb3b
perf: cut query counts in half
pomegranited Jul 2, 2025
195c73e
Merge remote-tracking branch 'origin/master' into chris/FAL-4180-sect…
pomegranited Jul 2, 2025
b2a0cd1
Merge branch 'master' into chris/FAL-4180-sections-subsections-publish
rpenido Jul 23, 2025
6c83d5e
chore: trigger ci
rpenido Jul 23, 2025
27d8418
chore: update openedx-learning constraint
rpenido Jul 23, 2025
addb0b5
Merge branch 'master' into chris/FAL-4180-sections-subsections-publish
rpenido Aug 14, 2025
df46ff6
chore: compile requirements
rpenido Aug 14, 2025
ef4fd07
test: updating query count
rpenido Aug 14, 2025
e29c69e
style: Add missing comment in kernel.in
ChrisChV Aug 15, 2025
3e969af
fix: get_container_from_key param and comments
rpenido Aug 16, 2025
1934a7d
docs: mark api as UNSTABLE and add comment about get_library_object_h…
rpenido Aug 16, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ def test_unit_sync(self):
'<problem display_name="Problem 3 Display Name" max_attempts="22">single select...</problem>'
)
self._add_container_children(self.upstream_unit["id"], [upstream_problem3["id"]])
self._remove_container_components(self.upstream_unit["id"], [self.upstream_problem2["id"]])
self._remove_container_children(self.upstream_unit["id"], [self.upstream_problem2["id"]])
self._commit_library_changes(self.library["id"]) # publish everything

status = self._get_sync_status(downstream_unit["locator"])
Expand Down Expand Up @@ -691,7 +691,7 @@ def test_unit_sync(self):
self.assertListEqual(data["results"], expected_downstreams)

# 4️⃣ Now, reorder components
self._patch_container_components(self.upstream_unit["id"], [
self._patch_container_children(self.upstream_unit["id"], [
upstream_problem3["id"],
self.upstream_problem1["id"],
self.upstream_html1["id"],
Expand Down
270 changes: 265 additions & 5 deletions openedx/core/djangoapps/content_libraries/api/container_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
"""
from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field as dataclass_field
from enum import Enum
from django.db.models import QuerySet

from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import Container
from openedx_learning.api.authoring_models import Container, Component, PublishableEntity

from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts
from openedx.core.djangoapps.xblock.api import get_component_from_usage_key

from .libraries import PublishableItem
from ..models import ContentLibrary
from .exceptions import ContentLibraryContainerNotFound
from .libraries import PublishableItem, library_component_usage_key

# The public API is only the following symbols:
__all__ = [
Expand Down Expand Up @@ -88,7 +92,7 @@ def from_container(cls, library_key, container: Container, associated_collection
container=container,
)
container_type = ContainerType(container_key.container_type)
published_by = ""
published_by = None
if last_publish_log and last_publish_log.published_by:
published_by = last_publish_log.published_by.username

Expand Down Expand Up @@ -121,6 +125,240 @@ def from_container(cls, library_key, container: Container, associated_collection
)


@dataclass(frozen=True, kw_only=True)
class ContainerHierarchy:
"""
Describes the full ancestry and descendents of a given library object.

TODO: We intend to replace this implementation with a more efficient one that makes fewer
database queries in the future. More details being discussed in
https://github.com/openedx/edx-platform/pull/36813#issuecomment-3136631767
"""
sections: list[ContainerHierarchyMember] = dataclass_field(default_factory=list)
subsections: list[ContainerHierarchyMember] = dataclass_field(default_factory=list)
units: list[ContainerHierarchyMember] = dataclass_field(default_factory=list)
components: list[ContainerHierarchyMember] = dataclass_field(default_factory=list)
object_key: LibraryUsageLocatorV2 | LibraryContainerLocator

class Level(Enum):
"""
Enumeratable levels contained by the ContainerHierarchy.
"""
none = 0
components = 1
units = 2
subsections = 3
sections = 4

def __bool__(self) -> bool:
"""
Level.none is False
All others are True.
"""
return self != ContainerHierarchy.Level.none

@property
def parent(self) -> ContainerHierarchy.Level:
"""
Returns the parent level above the given level,
or Level.none if this is already the top level.
"""
if not self:
return self
try:
return ContainerHierarchy.Level(self.value + 1)
except ValueError:
return ContainerHierarchy.Level.none

@property
def child(self) -> ContainerHierarchy.Level:
"""
Returns the name of the child field below the given level,
or None if level is already the lowest level.
"""
if not self:
return self
try:
return ContainerHierarchy.Level(self.value - 1)
except ValueError:
return ContainerHierarchy.Level.none

def append(
self,
level: Level,
*items: Component | Container,
) -> list[ContainerHierarchyMember]:
"""
Appends the metadata for the given items to the given level of the hierarchy.
Returns the resulting list.

Arguments:
* level: a valid Level (not Level.none)
* ...list of Components or Containers to add to this level.
"""
assert level
for item in items:
getattr(self, level.name).append(
ContainerHierarchyMember.create(
self.object_key.context_key,
item,
)
)

return getattr(self, level.name)

@classmethod
def create_from_library_object_key(
cls,
object_key: LibraryUsageLocatorV2 | LibraryContainerLocator,
):
"""
Returns a ContainerHierarchy populated from the library object represented by the given object_key.
"""
root_items: list[Component] | list[Container]
root_level: ContainerHierarchy.Level

if isinstance(object_key, LibraryUsageLocatorV2):
root_items = [get_component_from_usage_key(object_key)]
root_level = ContainerHierarchy.Level.components

elif isinstance(object_key, LibraryContainerLocator):
root_items = [get_container_from_key(object_key)]
root_level = ContainerHierarchy.Level[f"{object_key.container_type}s"]

if not root_level:
raise TypeError(f"Unexpected '{object_key}': must be LibraryUsageLocatorv2 or LibraryContainerLocator")

# Fill in root level of hierarchy
hierarchy = cls(object_key=object_key)
root_members = hierarchy.append(root_level, *root_items)

# Fill in hierarchy up through parents
level = root_level
members = root_members
while level := level.parent:
items = list(_get_containers_with_entities(members).all())
members = hierarchy.append(level, *items)

# Fill in hierarchy down from root_level.
if root_level != cls.Level.components: # Components have no children
level = root_level
members = root_members
while level := level.child:
children = _get_containers_children(level, members)
members = hierarchy.append(level, *children)

return hierarchy


def _get_containers_with_entities(
members: list[ContainerHierarchyMember],
*,
ignore_pinned=False,
) -> QuerySet[Container]:
"""
Find all draft containers that directly contain the given entities.

Args:
entities: iterable list or queryset of PublishableEntities.
ignore_pinned: if true, ignore any pinned references to the entity.
"""
qs = Container.objects.none()
for member in members:
qs = qs.union(authoring_api.get_containers_with_entity(
member.entity.pk,
ignore_pinned=ignore_pinned,
))
return qs


def _get_containers_children(
level: ContainerHierarchy.Level,
members: list[ContainerHierarchyMember],
*,
published=False,
) -> list[Component | Container]:
"""
Find all components or containers directly contained by the given hierarchy members.

Args:
containers: iterable list or queryset of Containers of the same type.
published: `True` if we want the published version of the children, or
`False` for the draft version.
"""
children: list[Component | Container] = []
for member in members:
container = member.container
assert container
for entry in authoring_api.get_entities_in_container(
container,
published=published,
):
match level:
case ContainerHierarchy.Level.components:
children.append(entry.entity_version.componentversion.component)
case _:
children.append(entry.entity_version.containerversion.container)

return children


@dataclass(frozen=True, kw_only=True)
class ContainerHierarchyMember:
"""
Represents an individual member of ContainerHierarchy which is ready to be serialized.
"""
id: LibraryContainerLocator | LibraryUsageLocatorV2
display_name: str
has_unpublished_changes: bool
component: Component | None
container: Container | None

@classmethod
def create(
cls,
library_key: LibraryLocatorV2,
entity: Container | Component,
) -> ContainerHierarchyMember:
"""
Creates a ContainerHierarchyMember.

Arguments:
* library_key: required for generating a usage/locator key for the given entitity.
* entity: the Container or Component
"""
if isinstance(entity, Component):
return ContainerHierarchyMember(
id=library_component_usage_key(library_key, entity),
display_name=entity.versioning.draft.title,
has_unpublished_changes=entity.versioning.has_unpublished_changes,
component=entity,
container=None,
)
assert isinstance(entity, Container)
return ContainerHierarchyMember(
id=library_container_locator(
library_key,
container=entity,
),
display_name=entity.versioning.draft.title,
has_unpublished_changes=authoring_api.contains_unpublished_changes(entity.pk),
container=entity,
component=None,
)

@property
def entity(self) -> PublishableEntity:
"""
Returns the PublishableEntity associated with this member.

Raises AssertError if there isn't a Component or Container set.
"""
entity = self.component or self.container
assert entity
return entity.publishable_entity


def library_container_locator(
library_key: LibraryLocatorV2,
container: Container,
Expand All @@ -145,3 +383,25 @@ def library_container_locator(
container_type=container_type.value,
container_id=container.publishable_entity.key,
)


def get_container_from_key(container_key: LibraryContainerLocator, include_deleted=False) -> Container:
"""
Internal method to fetch the Container object from its LibraryContainerLocator

Raises ContentLibraryContainerNotFound if no container found, or if the container has been soft deleted.
"""
assert isinstance(container_key, LibraryContainerLocator)
content_library = ContentLibrary.objects.get_by_key(container_key.lib_key)
learning_package = content_library.learning_package
assert learning_package is not None
container = authoring_api.get_container_by_key(
learning_package.id,
key=container_key.container_id,
)
# We only return the container if it exists and either:
# 1. the container has a draft version (which means it is not soft-deleted) OR
# 2. the container was soft-deleted but the `include_deleted` flag is set to True
if container and (include_deleted or container.versioning.draft):
return container
raise ContentLibraryContainerNotFound
Loading
Loading