From 6f868486db5b27f7ebf6b4039764163df1dc2390 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 12 Mar 2025 16:00:33 -0700 Subject: [PATCH 01/25] refactor: convert libraries API from attr.s to dataclass, fix types --- .../core/djangoapps/content_libraries/api.py | 200 ++++++++++-------- 1 file changed, 107 insertions(+), 93 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 15d628a74aed..36ace3f84fb2 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -52,14 +52,13 @@ import abc import collections +from dataclasses import dataclass, field from datetime import datetime, timezone import base64 import hashlib import logging import mimetypes - -import attr import requests from django.conf import settings @@ -114,6 +113,7 @@ xblock_type_display_name, ) from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core +from openedx.core.types import User as UserType from xmodule.modulestore.django import modulestore from . import permissions, tasks @@ -168,35 +168,37 @@ class LibraryPermissionIntegrityError(IntegrityError): # ====== -@attr.s +@dataclass(frozen=True) class ContentLibraryMetadata: """ Class that represents the metadata about a content library. """ - key = attr.ib(type=LibraryLocatorV2) - learning_package = attr.ib(type=LearningPackage) - title = attr.ib("") - description = attr.ib("") - num_blocks = attr.ib(0) - version = attr.ib(0) - last_published = attr.ib(default=None, type=datetime) - last_draft_created = attr.ib(default=None, type=datetime) - last_draft_created_by = attr.ib(default=None, type=datetime) - published_by = attr.ib("") - has_unpublished_changes = attr.ib(False) + key: LibraryLocatorV2 + learning_package_id: int | None + title: str = "" + description: str = "" + num_blocks: int = 0 + version: int = 0 + last_published: datetime | None = None + # The username of the user who last published this + published_by: str = "" + last_draft_created: datetime | None = None + # The username of the user who created the last draft. + last_draft_created_by: str = "" + has_unpublished_changes: bool = False # has_unpublished_deletes will be true when the draft version of the library's bundle # contains deletes of any XBlocks that were in the most recently published version - has_unpublished_deletes = attr.ib(False) - allow_lti = attr.ib(False) + has_unpublished_deletes: bool = False + allow_lti: bool = False # Allow any user (even unregistered users) to view and interact directly # with this library's content in the LMS - allow_public_learning = attr.ib(False) + allow_public_learning: bool = False # Allow any user with Studio access to view this library's content in # Studio, use it in their courses, and copy content out of this library. - allow_public_read = attr.ib(False) - license = attr.ib("") - created = attr.ib(default=None, type=datetime) - updated = attr.ib(default=None, type=datetime) + allow_public_read: bool = False + license: str = "" + created: datetime | None = None + updated: datetime | None = None class AccessLevel: @@ -207,43 +209,44 @@ class AccessLevel: NO_ACCESS = None -@attr.s +@dataclass(frozen=True) class ContentLibraryPermissionEntry: """ A user or group granted permission to use a content library. """ - user = attr.ib(type=AbstractUser, default=None) - group = attr.ib(type=Group, default=None) - access_level = attr.ib(AccessLevel.NO_ACCESS) + user: AbstractUser | None = None + group: Group | None = None + access_level: str | None = AccessLevel.NO_ACCESS # TODO: make this a proper enum? -@attr.s +@dataclass(frozen=True) class CollectionMetadata: """ Class to represent collection metadata in a content library. """ - key = attr.ib(type=str) - title = attr.ib(type=str) + key: str + title: str -@attr.s +@dataclass(frozen=True) class LibraryXBlockMetadata: """ Class that represents the metadata about an XBlock in a content library. """ - usage_key = attr.ib(type=LibraryUsageLocatorV2) - created = attr.ib(type=datetime) - modified = attr.ib(type=datetime) - draft_version_num = attr.ib(type=int) - published_version_num = attr.ib(default=None, type=int) - display_name = attr.ib("") - last_published = attr.ib(default=None, type=datetime) - last_draft_created = attr.ib(default=None, type=datetime) - last_draft_created_by = attr.ib("") - published_by = attr.ib("") - has_unpublished_changes = attr.ib(False) - created = attr.ib(default=None, type=datetime) - collections = attr.ib(type=list[CollectionMetadata], factory=list) + usage_key: LibraryUsageLocatorV2 + created: datetime + modified: datetime + draft_version_num: int + published_version_num: int | None = None + display_name: str = "" + last_published: datetime | None = None + # THe username of the user who last published this. + published_by: str = "" + last_draft_created: datetime | None = None + # The username of the user who created the last draft. + last_draft_created_by: str = "" + has_unpublished_changes: bool = False + collections: list[CollectionMetadata] = field(default_factory=list) @classmethod def from_component(cls, library_key, component, associated_collections=None): @@ -280,7 +283,7 @@ def from_component(cls, library_key, component, associated_collections=None): ) -@attr.s +@dataclass(frozen=True) class LibraryXBlockStaticFile: """ Class that represents a static file in a content library, associated with @@ -288,20 +291,20 @@ class LibraryXBlockStaticFile: """ # File path e.g. "diagram.png" # In some rare cases it might contain a folder part, e.g. "en/track1.srt" - path = attr.ib("") + path: str # Publicly accessible URL where the file can be downloaded - url = attr.ib("") + url: str # Size in bytes - size = attr.ib(0) + size: int -@attr.s +@dataclass(frozen=True) class LibraryXBlockType: """ An XBlock type that can be added to a content library """ - block_type = attr.ib("") - display_name = attr.ib("") + block_type: str + display_name: str # General APIs @@ -315,7 +318,7 @@ def user_can_create_library(user: AbstractUser) -> bool: return user.has_perm(permissions.CAN_CREATE_CONTENT_LIBRARY) -def get_libraries_for_user(user, org=None, text_search=None, order=None): +def get_libraries_for_user(user, org=None, text_search=None, order=None) -> QuerySet[ContentLibrary]: """ Return content libraries that the user has permission to view. """ @@ -352,7 +355,7 @@ def get_libraries_for_user(user, org=None, text_search=None, order=None): return filtered -def get_metadata(queryset, text_search=None): +def get_metadata(queryset: QuerySet[ContentLibrary], text_search: str | None = None) -> list[ContentLibraryMetadata]: """ Take a list of ContentLibrary objects and return metadata from Learning Core. """ @@ -380,14 +383,14 @@ def get_metadata(queryset, text_search=None): has_unpublished_changes=False, has_unpublished_deletes=False, license=lib.license, - learning_package=lib.learning_package, + learning_package_id=lib.learning_package_id, ) for lib in queryset ] return libraries -def require_permission_for_library_key(library_key, user, permission) -> ContentLibrary: +def require_permission_for_library_key(library_key: LibraryLocatorV2, user: UserType, permission) -> ContentLibrary: """ Given any of the content library permission strings defined in openedx.core.djangoapps.content_libraries.permissions, @@ -404,7 +407,7 @@ def require_permission_for_library_key(library_key, user, permission) -> Content return library_obj -def get_library(library_key): +def get_library(library_key: LibraryLocatorV2) -> ContentLibraryMetadata: """ Get the library with the specified key. Does not check permissions. returns a ContentLibraryMetadata instance. @@ -418,7 +421,7 @@ def get_library(library_key): last_draft_log = authoring_api.get_entities_with_unpublished_changes(learning_package.id) \ .order_by('-created').first() last_draft_created = last_draft_log.created if last_draft_log else None - last_draft_created_by = last_draft_log.created_by.username if last_draft_log and last_draft_log.created_by else None + last_draft_created_by = last_draft_log.created_by.username if last_draft_log and last_draft_log.created_by else "" has_unpublished_changes = last_draft_log is not None # TODO: I'm doing this one to match already-existing behavior, but this is @@ -445,7 +448,7 @@ def get_library(library_key): # libraries. The top level version stays for now because LegacyLibraryContentBlock # uses it, but that should hopefully change before the Redwood release. version = 0 if last_publish_log is None else last_publish_log.pk - published_by = None + published_by = "" if last_publish_log and last_publish_log.published_by: published_by = last_publish_log.published_by.username @@ -467,19 +470,19 @@ def get_library(library_key): license=ref.license, created=learning_package.created, updated=learning_package.updated, - learning_package=learning_package + learning_package_id=learning_package.pk, ) def create_library( - org, - slug, - title, - description="", - allow_public_learning=False, - allow_public_read=False, - library_license=ALL_RIGHTS_RESERVED, -): + org: str, + slug: str, + title: str, + description: str = "", + allow_public_learning: bool = False, + allow_public_read: bool = False, + library_license: str = ALL_RIGHTS_RESERVED, +) -> ContentLibraryMetadata: """ Create a new content library. @@ -534,11 +537,11 @@ def create_library( allow_public_learning=ref.allow_public_learning, allow_public_read=ref.allow_public_read, license=library_license, - learning_package=ref.learning_package + learning_package_id=ref.learning_package.pk, ) -def get_library_team(library_key): +def get_library_team(library_key: LibraryLocatorV2) -> list[ContentLibraryPermissionEntry]: """ Get the list of users/groups granted permission to use this library. """ @@ -549,7 +552,7 @@ def get_library_team(library_key): ] -def get_library_user_permissions(library_key, user): +def get_library_user_permissions(library_key: LibraryLocatorV2, user: UserType) -> ContentLibraryPermissionEntry | None: """ Fetch the specified user's access information. Will return None if no permissions have been granted. @@ -565,7 +568,7 @@ def get_library_user_permissions(library_key, user): ) -def set_library_user_permissions(library_key, user, access_level): +def set_library_user_permissions(library_key: LibraryLocatorV2, user: UserType, access_level: str | None): """ Change the specified user's level of access to this library. @@ -587,7 +590,7 @@ def set_library_user_permissions(library_key, user, access_level): ) -def set_library_group_permissions(library_key, group, access_level): +def set_library_group_permissions(library_key: LibraryLocatorV2, group, access_level: str): """ Change the specified group's level of access to this library. @@ -606,12 +609,12 @@ def set_library_group_permissions(library_key, group, access_level): def update_library( - library_key, - title=None, - description=None, - allow_public_learning=None, - allow_public_read=None, - library_license=None, + library_key: LibraryLocatorV2, + title=None, + description=None, + allow_public_learning=None, + allow_public_read=None, + library_license=None, ): """ Update a library's metadata @@ -659,7 +662,7 @@ def update_library( return content_lib -def delete_library(library_key): +def delete_library(library_key: LibraryLocatorV2) -> None: """ Delete a content library """ @@ -681,7 +684,7 @@ def delete_library(library_key): ) -def _get_library_component_tags_count(library_key) -> dict: +def _get_library_component_tags_count(library_key: LibraryLocatorV2) -> dict: """ Get the count of tags that are applied to each component in this library, as a dict. """ @@ -694,9 +697,9 @@ def _get_library_component_tags_count(library_key) -> dict: def get_library_components( - library_key, - text_search=None, - block_types=None, + library_key: LibraryLocatorV2, + text_search: str | None = None, + block_types: list[str] | None = None, ) -> QuerySet[Component]: """ Get the library components and filter. @@ -717,7 +720,7 @@ def get_library_components( return components -def get_library_block(usage_key, include_collections=False) -> LibraryXBlockMetadata: +def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=False) -> LibraryXBlockMetadata: """ Get metadata about (the draft version of) one specific XBlock in a library. @@ -755,7 +758,7 @@ def get_library_block(usage_key, include_collections=False) -> LibraryXBlockMeta return xblock_metadata -def set_library_block_olx(usage_key, new_olx_str) -> ComponentVersion: +def set_library_block_olx(usage_key: LibraryUsageLocatorV2, new_olx_str: str) -> ComponentVersion: """ Replace the OLX source of the given XBlock. @@ -765,7 +768,6 @@ def set_library_block_olx(usage_key, new_olx_str) -> ComponentVersion: Returns the version number of the newly created ComponentVersion. """ - # because this old pylint can't understand attr.ib() objects, pylint: disable=no-member assert isinstance(usage_key, LibraryUsageLocatorV2) # HTMLBlock uses CDATA to preserve HTML inside the XML, so make sure we @@ -937,6 +939,8 @@ def import_staged_content_from_user_clipboard(library_key: LibraryLocatorV2, use staged_content_id = user_clipboard.content.id olx_str = content_staging_api.get_staged_content_olx(staged_content_id) + if olx_str is None: + return None # Shouldn't happen since we checked that the clipboard exists - mostly here for type checker staged_content_files = content_staging_api.get_staged_content_static_files(staged_content_id) content_library, usage_key = validate_can_add_block_to_library( @@ -1054,7 +1058,11 @@ def get_or_create_olx_media_type(block_type: str) -> MediaType: ) -def _create_component_for_block(content_lib, usage_key, user_id=None): +def _create_component_for_block( + content_lib: ContentLibrary, + usage_key: LibraryUsageLocatorV2, + user_id: int | None = None, +): """ Create a Component for an XBlock type, initialize it, and return the ComponentVersion. @@ -1074,6 +1082,7 @@ def _create_component_for_block(content_lib, usage_key, user_id=None): xml_text = f'<{usage_key.block_type} />' learning_package = content_lib.learning_package + assert learning_package is not None # mostly for type checker with transaction.atomic(): component_type = authoring_api.get_or_create_component_type( @@ -1102,7 +1111,7 @@ def _create_component_for_block(content_lib, usage_key, user_id=None): return component_version -def delete_library_block(usage_key, remove_from_parent=True): +def delete_library_block(usage_key: LibraryUsageLocatorV2, remove_from_parent=True) -> None: """ Delete the specified block from this library (soft delete). """ @@ -1133,7 +1142,7 @@ def delete_library_block(usage_key, remove_from_parent=True): ) -def restore_library_block(usage_key): +def restore_library_block(usage_key: LibraryUsageLocatorV2) -> None: """ Restore the specified library block. """ @@ -1173,7 +1182,7 @@ def restore_library_block(usage_key): ) -def get_library_block_static_asset_files(usage_key) -> list[LibraryXBlockStaticFile]: +def get_library_block_static_asset_files(usage_key: LibraryUsageLocatorV2) -> list[LibraryXBlockStaticFile]: """ Given an XBlock in a content library, list all the static asset files associated with that XBlock. @@ -1216,7 +1225,12 @@ def get_library_block_static_asset_files(usage_key) -> list[LibraryXBlockStaticF ] -def add_library_block_static_asset_file(usage_key, file_path, file_content, user=None) -> LibraryXBlockStaticFile: +def add_library_block_static_asset_file( + usage_key: LibraryUsageLocatorV2, + file_path: str, + file_content: bytes, + user: UserType | None = None, +) -> LibraryXBlockStaticFile: """ Upload a static asset file into the library, to be associated with the specified XBlock. Will silently overwrite an existing file of the same name. @@ -1308,7 +1322,7 @@ def delete_library_block_static_asset_file(usage_key, file_path, user=None): ) -def get_allowed_block_types(library_key): # pylint: disable=unused-argument +def get_allowed_block_types(library_key: LibraryLocatorV2): # pylint: disable=unused-argument """ Get a list of XBlock types that can be added to the specified content library. @@ -1337,7 +1351,7 @@ def get_allowed_block_types(library_key): # pylint: disable=unused-argument return info -def publish_changes(library_key, user_id=None): +def publish_changes(library_key: LibraryLocatorV2, user_id: int | None = None): """ Publish all pending changes to the specified library. """ @@ -1353,7 +1367,7 @@ def publish_changes(library_key, user_id=None): ) -def publish_component_changes(usage_key: LibraryUsageLocatorV2, user): +def publish_component_changes(usage_key: LibraryUsageLocatorV2, user: UserType): """ Publish all pending changes in a single component. """ @@ -1378,7 +1392,7 @@ def publish_component_changes(usage_key: LibraryUsageLocatorV2, user): ) -def revert_changes(library_key): +def revert_changes(library_key: LibraryLocatorV2) -> None: """ Revert all pending changes to the specified library, restoring it to the last published version. From 0508e3b2e5aee79768e7c1f9b96f5d13378fd7a0 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 13 Mar 2025 22:33:26 -0700 Subject: [PATCH 02/25] fix: make corresponding updates to 'search' code --- openedx/core/djangoapps/content/search/api.py | 4 ++-- .../content/search/tests/test_documents.py | 19 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index a18c55bd3d22..dd8cfcdc891d 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -482,7 +482,7 @@ def index_collection_batch(batch, num_done, library_key) -> int: # To reduce memory usage on large instances, split up the Collections into pages of 100 collections: library = lib_api.get_library(lib_key) - collections = authoring_api.get_collections(library.learning_package.id, enabled=True) + collections = authoring_api.get_collections(library.learning_package_id, enabled=True) num_collections = collections.count() num_collections_done = 0 status_cb(f"{num_collections_done + 1}/{num_collections}. Now indexing collections in library {lib_key}") @@ -711,7 +711,7 @@ def update_library_components_collections( Because there may be a lot of components, we send these updates to Meilisearch in batches. """ library = lib_api.get_library(library_key) - components = authoring_api.get_collection_components(library.learning_package.id, collection_key) + components = authoring_api.get_collection_components(library.learning_package_id, collection_key) paginator = Paginator(components, batch_size) for page in paginator.page_range: diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index b4f7eb2b62c1..3bab2795b9f5 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -1,6 +1,7 @@ """ Tests for the Studio content search documents (what gets stored in the index) """ +from dataclasses import replace from datetime import datetime, timezone from organizations.models import Organization @@ -427,18 +428,16 @@ def test_html_published_library_block(self): } # Verify publish status is set to modified - old_modified = self.library_block.modified - old_published = self.library_block.last_published - self.library_block.modified = datetime(2024, 4, 5, 6, 7, 8, tzinfo=timezone.utc) - self.library_block.last_published = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) - doc = searchable_doc_for_library_block(self.library_block) - doc.update(searchable_doc_tags(self.library_block.usage_key)) - doc.update(searchable_doc_collections(self.library_block.usage_key)) + library_block_modified = replace( + self.library_block, + modified=datetime(2024, 4, 5, 6, 7, 8, tzinfo=timezone.utc), + last_published=datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc), + ) + doc = searchable_doc_for_library_block(library_block_modified) + doc.update(searchable_doc_tags(library_block_modified.usage_key)) + doc.update(searchable_doc_collections(library_block_modified.usage_key)) assert doc["publish_status"] == "modified" - self.library_block.modified = old_modified - self.library_block.last_published = old_published - def test_collection_with_library(self): doc = searchable_doc_for_collection(self.library.key, self.collection.key) doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection.key)) From 88ba74f5603336dfa0e053c33cb88311356b4603 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 12 Mar 2025 16:49:29 -0700 Subject: [PATCH 03/25] feat: use new version of openedx-learning with containers support --- cms/envs/common.py | 1 + lms/envs/common.py | 1 + requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 7 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 6f49a8623684..11baf36de060 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1876,6 +1876,7 @@ "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", + "openedx_learning.apps.authoring.units", ] diff --git a/lms/envs/common.py b/lms/envs/common.py index 9b1bd2391398..503cda415032 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3373,6 +3373,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", + "openedx_learning.apps.authoring.units", ] diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 045724797add..96dffb229be1 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -131,7 +131,7 @@ optimizely-sdk<5.0 # Date: 2023-09-18 # pinning this version to avoid updates while the library is being developed # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-learning==0.18.3 +openedx-learning==0.19.0 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 30488b4a99da..33351eb9541e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -832,7 +832,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.8 # via -r requirements/edx/kernel.in -openedx-learning==0.18.3 +openedx-learning==0.19.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 0aca6d480ced..e8c4b4a37ad1 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1397,7 +1397,7 @@ openedx-forum==0.1.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-learning==0.18.3 +openedx-learning==0.19.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index ce5f9bf2e29e..a96678ab0206 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1004,7 +1004,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.8 # via -r requirements/edx/base.txt -openedx-learning==0.18.3 +openedx-learning==0.19.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index cf08a75d4606..8b65ef14b9a7 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1062,7 +1062,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.8 # via -r requirements/edx/base.txt -openedx-learning==0.18.3 +openedx-learning==0.19.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 144ff018e464b5e2fb15d9caf4fc3fc89b33a81e Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 13 Mar 2025 16:33:24 -0500 Subject: [PATCH 04/25] temp: Use opencraft branch of opaquekeys --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/kernel.in | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 33351eb9541e..42cd0e9de831 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -489,7 +489,7 @@ edx-milestones==0.6.0 # via -r requirements/edx/kernel.in edx-name-affirmation==3.0.1 # via -r requirements/edx/kernel.in -edx-opaque-keys[django]==2.11.0 +edx-opaque-keys[django] @ git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys # via # -r requirements/edx/kernel.in # edx-bulk-grades diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index e8c4b4a37ad1..cb7b3407f49d 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -780,7 +780,7 @@ edx-name-affirmation==3.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-opaque-keys[django]==2.11.0 +edx-opaque-keys[django] @ git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index a96678ab0206..ea090a667575 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -573,7 +573,7 @@ edx-milestones==0.6.0 # via -r requirements/edx/base.txt edx-name-affirmation==3.0.1 # via -r requirements/edx/base.txt -edx-opaque-keys[django]==2.11.0 +edx-opaque-keys[django] @ git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys # via # -r requirements/edx/base.txt # edx-bulk-grades diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index a17b9db4c868..4b8df657decd 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -75,7 +75,7 @@ edx-event-bus-kafka>=5.6.0 # Kafka implementation of event bus edx-event-bus-redis edx-milestones edx-name-affirmation -edx-opaque-keys +git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys#egg=edx-opaque-keys edx-organizations edx-proctoring>=2.0.1 # using hash to support django42 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 8b65ef14b9a7..f76468b329ad 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -600,7 +600,7 @@ edx-milestones==0.6.0 # via -r requirements/edx/base.txt edx-name-affirmation==3.0.1 # via -r requirements/edx/base.txt -edx-opaque-keys[django]==2.11.0 +edx-opaque-keys[django] @ git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys # via # -r requirements/edx/base.txt # edx-bulk-grades From d125b04fcaaca66a0068dd66ca139d1f3fd835cf Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 13 Mar 2025 16:34:04 -0500 Subject: [PATCH 05/25] refactor: Use LibraryElementKey instead of LibraryCollectionKey --- openedx/core/djangoapps/content_tagging/api.py | 4 ++-- .../core/djangoapps/content_tagging/tests/test_api.py | 6 +++--- openedx/core/djangoapps/content_tagging/types.py | 4 ++-- openedx/core/djangoapps/content_tagging/utils.py | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py index 96f6886b622b..8a11d8d1cb6b 100644 --- a/openedx/core/djangoapps/content_tagging/api.py +++ b/openedx/core/djangoapps/content_tagging/api.py @@ -12,7 +12,7 @@ import openedx_tagging.core.tagging.api as oel_tagging from django.db.models import Exists, OuterRef, Q, QuerySet from django.utils.timezone import now -from opaque_keys.edx.keys import CourseKey, LibraryCollectionKey +from opaque_keys.edx.keys import CourseKey, LibraryElementKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy from openedx_tagging.core.tagging.models.utils import TAGS_CSV_SEPARATOR @@ -230,7 +230,7 @@ def generate_csv_rows(object_id, buffer) -> Iterator[str]: """ content_key = get_content_key_from_string(object_id) - if isinstance(content_key, (UsageKey, LibraryCollectionKey)): + if isinstance(content_key, (UsageKey, LibraryElementKey)): raise ValueError("The object_id must be a CourseKey or a LibraryLocatorV2.") all_object_tags, taxonomies = get_all_object_tags(content_key) diff --git a/openedx/core/djangoapps/content_tagging/tests/test_api.py b/openedx/core/djangoapps/content_tagging/tests/test_api.py index b693f7ee0f56..d85c87d62b17 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_api.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_api.py @@ -5,8 +5,8 @@ import ddt from django.test.testcases import TestCase from fs.osfs import OSFS -from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryCollectionKey -from opaque_keys.edx.locator import LibraryLocatorV2 +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator from openedx_tagging.core.tagging.models import ObjectTag from organizations.models import Organization from .test_objecttag_export_helpers import TestGetAllObjectTagsMixin, TaggedCourseMixin @@ -381,7 +381,7 @@ def test_copy_cross_org_tags(self): self._test_copy_object_tags(src_key, dst_key, expected_tags) def test_tag_collection(self): - collection_key = LibraryCollectionKey.from_string("lib-collection:orgA:libX:1") + collection_key = LibraryCollectionLocator.from_string("lib-collection:orgA:libX:1") api.tag_object( object_id=str(collection_key), diff --git a/openedx/core/djangoapps/content_tagging/types.py b/openedx/core/djangoapps/content_tagging/types.py index 9ffb090d61e3..c630a8bff107 100644 --- a/openedx/core/djangoapps/content_tagging/types.py +++ b/openedx/core/djangoapps/content_tagging/types.py @@ -5,11 +5,11 @@ from typing import Dict, List, Union -from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryCollectionKey +from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryElementKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import Taxonomy -ContentKey = Union[LibraryLocatorV2, CourseKey, UsageKey, LibraryCollectionKey] +ContentKey = Union[LibraryLocatorV2, CourseKey, UsageKey, LibraryElementKey] ContextKey = Union[LibraryLocatorV2, CourseKey] TagValuesByTaxonomyIdDict = Dict[int, List[str]] diff --git a/openedx/core/djangoapps/content_tagging/utils.py b/openedx/core/djangoapps/content_tagging/utils.py index 39dd925c1acd..7373e5190ae1 100644 --- a/openedx/core/djangoapps/content_tagging/utils.py +++ b/openedx/core/djangoapps/content_tagging/utils.py @@ -5,7 +5,7 @@ from edx_django_utils.cache import RequestCache from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryCollectionKey +from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryElementKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import Taxonomy from organizations.models import Organization @@ -28,11 +28,11 @@ def get_content_key_from_string(key_str: str) -> ContentKey: return UsageKey.from_string(key_str) except InvalidKeyError: try: - return LibraryCollectionKey.from_string(key_str) + return LibraryElementKey.from_string(key_str) except InvalidKeyError as usage_key_error: raise ValueError( "object_id must be one of the following " - "keys: CourseKey, LibraryLocatorV2, UsageKey or LibCollectionKey" + "keys: CourseKey, LibraryLocatorV2, UsageKey or LibraryElementKey" ) from usage_key_error @@ -44,8 +44,8 @@ def get_context_key_from_key(content_key: ContentKey) -> ContextKey: if isinstance(content_key, (CourseKey, LibraryLocatorV2)): return content_key - # If the content key is a LibraryCollectionKey, return the LibraryLocatorV2 - if isinstance(content_key, LibraryCollectionKey): + # If the content key is a LibraryElementKey, return the LibraryLocatorV2 + if isinstance(content_key, LibraryElementKey): return content_key.library_key # If the content key is a UsageKey, return the context key From 0b8e12c7f4663ec67a13dfb544cce47257fa5ea9 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 13 Mar 2025 19:34:48 -0700 Subject: [PATCH 06/25] refactor: split libraries API & REST API up into smaller modules --- .../content_libraries/api/__init__.py | 6 ++ .../content_libraries/api/blocks.py | 30 +++++++ .../{api.py => api/libraries.py} | 80 ++++++++++++++++--- .../content_libraries/api/permissions.py | 14 ++++ .../djangoapps/content_libraries/models.py | 7 +- .../content_libraries/rest_api/__init__.py | 0 .../content_libraries/rest_api/blocks.py | 19 +++++ .../collections.py} | 9 +-- .../{views.py => rest_api/libraries.py} | 45 +---------- .../{ => rest_api}/serializers.py | 2 +- .../content_libraries/rest_api/utils.py | 49 ++++++++++++ .../content_libraries/tests/test_api.py | 28 +++---- .../tests/test_content_libraries.py | 14 +++- .../core/djangoapps/content_libraries/urls.py | 55 +++++++------ 14 files changed, 251 insertions(+), 107 deletions(-) create mode 100644 openedx/core/djangoapps/content_libraries/api/__init__.py create mode 100644 openedx/core/djangoapps/content_libraries/api/blocks.py rename openedx/core/djangoapps/content_libraries/{api.py => api/libraries.py} (96%) create mode 100644 openedx/core/djangoapps/content_libraries/api/permissions.py create mode 100644 openedx/core/djangoapps/content_libraries/rest_api/__init__.py create mode 100644 openedx/core/djangoapps/content_libraries/rest_api/blocks.py rename openedx/core/djangoapps/content_libraries/{views_collections.py => rest_api/collections.py} (96%) rename openedx/core/djangoapps/content_libraries/{views.py => rest_api/libraries.py} (96%) rename openedx/core/djangoapps/content_libraries/{ => rest_api}/serializers.py (99%) create mode 100644 openedx/core/djangoapps/content_libraries/rest_api/utils.py diff --git a/openedx/core/djangoapps/content_libraries/api/__init__.py b/openedx/core/djangoapps/content_libraries/api/__init__.py new file mode 100644 index 000000000000..ad97436fe3ac --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/__init__.py @@ -0,0 +1,6 @@ +""" +Python API for working with content libraries +""" +from .libraries import * +from .blocks import * +from . import permissions diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py new file mode 100644 index 000000000000..383a8d8fbd07 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -0,0 +1,30 @@ +""" +Content libraries API methods related to XBlocks/Components. + +These methods don't enforce permissions (only the REST APIs do). +""" +# pylint: disable=unused-import + +# TODO: move all the API methods related to blocks and assets in here from 'libraries.py' +# TODO: use __all__ to limit what symbols are public. + +from .libraries import ( + LibraryXBlockMetadata, + LibraryXBlockStaticFile, + LibraryXBlockType, + get_library_components, + get_library_block, + set_library_block_olx, + library_component_usage_key, + get_component_from_usage_key, + validate_can_add_block_to_library, + create_library_block, + import_staged_content_from_user_clipboard, + get_or_create_olx_media_type, + delete_library_block, + restore_library_block, + get_library_block_static_asset_files, + add_library_block_static_asset_file, + delete_library_block_static_asset_file, + publish_component_changes, +) diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api/libraries.py similarity index 96% rename from openedx/core/djangoapps/content_libraries/api.py rename to openedx/core/djangoapps/content_libraries/api/libraries.py index 36ace3f84fb2..c89c573fd5a1 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -62,7 +62,7 @@ import requests from django.conf import settings -from django.contrib.auth.models import AbstractUser, Group +from django.contrib.auth.models import AbstractUser, AnonymousUser, Group from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.validators import validate_unicode_slug from django.db import IntegrityError, transaction @@ -116,12 +116,60 @@ from openedx.core.types import User as UserType from xmodule.modulestore.django import modulestore -from . import permissions, tasks -from .constants import ALL_RIGHTS_RESERVED -from .models import ContentLibrary, ContentLibraryPermission, ContentLibraryBlockImportTask +from .. import permissions, tasks +from ..constants import ALL_RIGHTS_RESERVED +from ..models import ContentLibrary, ContentLibraryPermission, ContentLibraryBlockImportTask log = logging.getLogger(__name__) +# The public API is only the following symbols: +__all__ = [ + # Exceptions - maybe move them to a new file? + "ContentLibraryNotFound", + "ContentLibraryCollectionNotFound", + "ContentLibraryBlockNotFound", + "LibraryAlreadyExists", + "LibraryCollectionAlreadyExists", + "LibraryBlockAlreadyExists", + "BlockLimitReachedError", + "IncompatibleTypesError", + "InvalidNameError", + "LibraryPermissionIntegrityError", + # Library Models + "ContentLibrary", # Should this be public or not? + "ContentLibraryMetadata", + "AccessLevel", + "ContentLibraryPermissionEntry", + "CollectionMetadata", + # Library API methods + "user_can_create_library", + "get_libraries_for_user", + "get_metadata", + "require_permission_for_library_key", + "get_library", + "create_library", + "get_library_team", + "get_library_user_permissions", + "set_library_user_permissions", + "set_library_group_permissions", + "update_library", + "delete_library", + "get_allowed_block_types", + "publish_changes", + "revert_changes", + # Collections - TODO: move to a new file + "create_library_collection", + "update_library_collection", + "update_library_collection_components", + "set_library_component_collections", + "get_library_collection_usage_key", + "get_library_collection_from_usage_key", + # Import - TODO: move to a new file + "EdxModulestoreImportClient", + "EdxApiImportClient", + "import_blocks_create_task", +] + # Exceptions # ========== @@ -416,6 +464,7 @@ def get_library(library_key: LibraryLocatorV2) -> ContentLibraryMetadata: """ ref = ContentLibrary.objects.get_by_key(library_key) learning_package = ref.learning_package + assert learning_package is not None # Shouldn't happen - this is just for the type checker num_blocks = authoring_api.get_all_drafts(learning_package.id).count() last_publish_log = authoring_api.get_last_publish(learning_package.id) last_draft_log = authoring_api.get_entities_with_unpublished_changes(learning_package.id) \ @@ -455,7 +504,7 @@ def get_library(library_key: LibraryLocatorV2) -> ContentLibraryMetadata: return ContentLibraryMetadata( key=library_key, title=learning_package.title, - description=ref.learning_package.description, + description=learning_package.description, num_blocks=num_blocks, version=version, last_published=None if last_publish_log is None else last_publish_log.published_at, @@ -557,6 +606,8 @@ def get_library_user_permissions(library_key: LibraryLocatorV2, user: UserType) Fetch the specified user's access information. Will return None if no permissions have been granted. """ + if isinstance(user, AnonymousUser): + return None # Mostly here for the type checker ref = ContentLibrary.objects.get_by_key(library_key) grant = ref.permission_grants.filter(user=user).first() if grant is None: @@ -574,6 +625,8 @@ def set_library_user_permissions(library_key: LibraryLocatorV2, user: UserType, access_level should be one of the AccessLevel values defined above. """ + if isinstance(user, AnonymousUser): + raise TypeError("Invalid user type") # Mostly here for the type checker ref = ContentLibrary.objects.get_by_key(library_key) current_grant = get_library_user_permissions(library_key, user) if current_grant and current_grant.access_level == AccessLevel.ADMIN_LEVEL: @@ -633,6 +686,8 @@ def update_library( return content_lib = ContentLibrary.objects.get_by_key(library_key) + learning_package_id = content_lib.learning_package_id + assert learning_package_id is not None with transaction.atomic(): # We need to make updates to both the ContentLibrary and its linked @@ -643,12 +698,12 @@ def update_library( if allow_public_read is not None: content_lib.allow_public_read = allow_public_read if library_license is not None: - content_lib.library_license = library_license + content_lib.license = library_license content_lib.save() if learning_pkg_changed: authoring_api.update_learning_package( - content_lib.learning_package_id, + learning_package_id, title=title, description=description, ) @@ -675,7 +730,8 @@ def delete_library(library_key: LibraryLocatorV2) -> None: # TODO: We should eventually detach the LearningPackage and delete it # asynchronously, especially if we need to delete a bunch of stuff # on the filesystem for it. - learning_package.delete() + if learning_package: + learning_package.delete() CONTENT_LIBRARY_DELETED.send_event( content_library=ContentLibraryData( @@ -709,6 +765,7 @@ def get_library_components( """ lib = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] learning_package = lib.learning_package + assert learning_package is not None components = authoring_api.get_components( learning_package.id, draft=True, @@ -860,7 +917,8 @@ def validate_can_add_block_to_library( content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] # If adding a component would take us over our max, return an error. - component_count = authoring_api.get_all_drafts(content_library.learning_package.id).count() + assert content_library.learning_package_id is not None + component_count = authoring_api.get_all_drafts(content_library.learning_package_id).count() if component_count + 1 > settings.MAX_BLOCKS_PER_CONTENT_LIBRARY: raise BlockLimitReachedError( _("Library cannot have more than {} Components").format( @@ -1356,7 +1414,7 @@ def publish_changes(library_key: LibraryLocatorV2, user_id: int | None = None): Publish all pending changes to the specified library. """ learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package - + assert learning_package is not None # shouldn't happen but it's technically possible. authoring_api.publish_all_drafts(learning_package.id, published_by=user_id) CONTENT_LIBRARY_UPDATED.send_event( @@ -1398,6 +1456,7 @@ def revert_changes(library_key: LibraryLocatorV2) -> None: last published version. """ learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package + assert learning_package is not None # shouldn't happen but it's technically possible. authoring_api.reset_drafts_to_published(learning_package.id) CONTENT_LIBRARY_UPDATED.send_event( @@ -1652,6 +1711,7 @@ def get_library_collection_from_usage_key( library_key = collection_usage_key.library_key collection_key = collection_usage_key.collection_id content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library.learning_package_id is not None # shouldn't happen but it's technically possible. try: return authoring_api.get_collection( content_library.learning_package_id, diff --git a/openedx/core/djangoapps/content_libraries/api/permissions.py b/openedx/core/djangoapps/content_libraries/api/permissions.py new file mode 100644 index 000000000000..6064b80d6f9e --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/permissions.py @@ -0,0 +1,14 @@ +""" +Public permissions that are part of the content libraries API +""" +# pylint: disable=unused-import + +from ..permissions import ( + CAN_CREATE_CONTENT_LIBRARY, + CAN_DELETE_THIS_CONTENT_LIBRARY, + CAN_EDIT_THIS_CONTENT_LIBRARY, + CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM, + CAN_LEARN_FROM_THIS_CONTENT_LIBRARY, + CAN_VIEW_THIS_CONTENT_LIBRARY, + CAN_VIEW_THIS_CONTENT_LIBRARY_TEAM +) diff --git a/openedx/core/djangoapps/content_libraries/models.py b/openedx/core/djangoapps/content_libraries/models.py index 61e28b944851..415821145605 100644 --- a/openedx/core/djangoapps/content_libraries/models.py +++ b/openedx/core/djangoapps/content_libraries/models.py @@ -36,6 +36,7 @@ import contextlib import logging +from typing import ClassVar import uuid from django.contrib.auth import get_user_model @@ -67,11 +68,11 @@ User = get_user_model() -class ContentLibraryManager(models.Manager): +class ContentLibraryManager(models.Manager["ContentLibrary"]): """ Custom manager for ContentLibrary class. """ - def get_by_key(self, library_key): + def get_by_key(self, library_key) -> "ContentLibrary": """ Get the ContentLibrary for the given LibraryLocatorV2 key. """ @@ -92,7 +93,7 @@ class ContentLibrary(models.Model): .. no_pii: """ - objects: ContentLibraryManager[ContentLibrary] = ContentLibraryManager() + objects: ClassVar[ContentLibraryManager] = ContentLibraryManager() id = models.AutoField(primary_key=True) # Every Library is uniquely and permanently identified by an 'org' and a diff --git a/openedx/core/djangoapps/content_libraries/rest_api/__init__.py b/openedx/core/djangoapps/content_libraries/rest_api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py new file mode 100644 index 000000000000..6c24c35394b9 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -0,0 +1,19 @@ +""" +Content Library REST APIs related to XBlocks/Components and their static assets +""" +# pylint: disable=unused-import + +# TODO: move the block and block asset related views from 'libraries' into this file +from .libraries import ( + LibraryBlockAssetListView, + LibraryBlockAssetView, + LibraryBlockCollectionsView, + LibraryBlockLtiUrlView, + LibraryBlockOlxView, + LibraryBlockPublishView, + LibraryBlockRestore, + LibraryBlocksView, + LibraryBlockView, + LibraryComponentAssetView, + LibraryComponentDraftAssetView, +) diff --git a/openedx/core/djangoapps/content_libraries/views_collections.py b/openedx/core/djangoapps/content_libraries/rest_api/collections.py similarity index 96% rename from openedx/core/djangoapps/content_libraries/views_collections.py rename to openedx/core/djangoapps/content_libraries/rest_api/collections.py index 21c4b12dd3da..f1b63b2c1845 100644 --- a/openedx/core/djangoapps/content_libraries/views_collections.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/collections.py @@ -1,7 +1,6 @@ """ Collections API Views """ - from __future__ import annotations from django.db.models import QuerySet @@ -17,10 +16,10 @@ from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Collection -from openedx.core.djangoapps.content_libraries import api, permissions -from openedx.core.djangoapps.content_libraries.models import ContentLibrary -from openedx.core.djangoapps.content_libraries.views import convert_exceptions -from openedx.core.djangoapps.content_libraries.serializers import ( +from .. import api, permissions +from ..models import ContentLibrary +from .utils import convert_exceptions +from .serializers import ( ContentLibraryCollectionSerializer, ContentLibraryCollectionComponentsUpdateSerializer, ContentLibraryCollectionUpdateSerializer, diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py similarity index 96% rename from openedx/core/djangoapps/content_libraries/views.py rename to openedx/core/djangoapps/content_libraries/rest_api/libraries.py index a1b7a2602450..5e370dc40e33 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py @@ -62,8 +62,6 @@ to Learning Core) atomic: https://github.com/openedx/edx-platform/pull/30456 """ - -from functools import wraps import itertools import json import logging @@ -86,7 +84,6 @@ from pylti1p3.exception import LtiException, OIDCException import edx_api_doc_tools as apidocs -from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_learning.api import authoring from organizations.api import ensure_organization @@ -105,7 +102,7 @@ user_can_create_organizations, ) from openedx.core.djangoapps.content_libraries import api, permissions -from openedx.core.djangoapps.content_libraries.serializers import ( +from openedx.core.djangoapps.content_libraries.rest_api.serializers import ( ContentLibraryBlockImportTaskCreateSerializer, ContentLibraryBlockImportTaskSerializer, ContentLibraryFilterSerializer, @@ -129,50 +126,14 @@ from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.types.http import RestRequest -from .models import ContentLibrary, LtiGradedResource, LtiProfile +from .utils import convert_exceptions +from ..models import ContentLibrary, LtiGradedResource, LtiProfile User = get_user_model() log = logging.getLogger(__name__) -def convert_exceptions(fn): - """ - Catch any Content Library API exceptions that occur and convert them to - DRF exceptions so DRF will return an appropriate HTTP response - """ - - @wraps(fn) - def wrapped_fn(*args, **kwargs): - try: - return fn(*args, **kwargs) - except InvalidKeyError as exc: - log.exception(str(exc)) - raise NotFound # lint-amnesty, pylint: disable=raise-missing-from - except api.ContentLibraryNotFound: - log.exception("Content library not found") - raise NotFound # lint-amnesty, pylint: disable=raise-missing-from - except api.ContentLibraryBlockNotFound: - log.exception("XBlock not found in content library") - raise NotFound # lint-amnesty, pylint: disable=raise-missing-from - except api.ContentLibraryCollectionNotFound: - log.exception("Collection not found in content library") - raise NotFound # lint-amnesty, pylint: disable=raise-missing-from - except api.LibraryCollectionAlreadyExists as exc: - log.exception(str(exc)) - raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from - except api.LibraryBlockAlreadyExists as exc: - log.exception(str(exc)) - raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from - except api.InvalidNameError as exc: - log.exception(str(exc)) - raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from - except api.BlockLimitReachedError as exc: - log.exception(str(exc)) - raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from - return wrapped_fn - - class LibraryApiPaginationDocs: """ API docs for query params related to paginating ContentLibraryMetadata objects. diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py similarity index 99% rename from openedx/core/djangoapps/content_libraries/serializers.py rename to openedx/core/djangoapps/content_libraries/rest_api/serializers.py index c2a26220d4c7..c902e375870b 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -19,7 +19,7 @@ ContentLibrary ) from openedx.core.lib.api.serializers import CourseKeyField -from . import permissions +from .. import permissions DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ' diff --git a/openedx/core/djangoapps/content_libraries/rest_api/utils.py b/openedx/core/djangoapps/content_libraries/rest_api/utils.py new file mode 100644 index 000000000000..53b5dd0b4059 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/rest_api/utils.py @@ -0,0 +1,49 @@ +""" +REST API utilities for content libraries +""" +from functools import wraps +import logging + +from opaque_keys import InvalidKeyError +from rest_framework.exceptions import NotFound, ValidationError + +from .. import api + +log = logging.getLogger(__name__) + + +def convert_exceptions(fn): + """ + Catch any Content Library API exceptions that occur and convert them to + DRF exceptions so DRF will return an appropriate HTTP response + """ + + @wraps(fn) + def wrapped_fn(*args, **kwargs): + try: + return fn(*args, **kwargs) + except InvalidKeyError as exc: + log.exception(str(exc)) + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryNotFound: + log.exception("Content library not found") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryBlockNotFound: + log.exception("XBlock not found in content library") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryCollectionNotFound: + log.exception("Collection not found in content library") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.LibraryCollectionAlreadyExists as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from + except api.LibraryBlockAlreadyExists as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from + except api.InvalidNameError as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from + except api.BlockLimitReachedError as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from + return wrapped_fn diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index 203cc7a9397a..d4be6fdf6bd9 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -65,11 +65,11 @@ def test_import_blocks_from_course_without_course(self): with self.assertRaises(ValueError): self.client.import_blocks_from_course('foobar', lambda *_: None) - @mock.patch('openedx.core.djangoapps.content_libraries.api.create_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.get_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.get_library_block_static_asset_files') - @mock.patch('openedx.core.djangoapps.content_libraries.api.publish_changes') - @mock.patch('openedx.core.djangoapps.content_libraries.api.set_library_block_olx') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.create_library_block') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.get_library_block') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.get_library_block_static_asset_files') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.publish_changes') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.set_library_block_olx') def test_import_blocks_from_course_on_block_with_olx( self, mock_set_library_block_olx, @@ -101,9 +101,9 @@ def test_import_blocks_from_course_on_block_with_olx( mock.ANY, 'fake-olx') mock_publish_changes.assert_called_once() - @mock.patch('openedx.core.djangoapps.content_libraries.api.create_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.get_library_block_static_asset_files') - @mock.patch('openedx.core.djangoapps.content_libraries.api.set_library_block_olx') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.create_library_block') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.get_library_block_static_asset_files') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.set_library_block_olx') def test_import_block_when_called_twice_same_block_but_different_course( self, mock_set_library_block_olx, @@ -138,7 +138,7 @@ def test_import_block_when_called_twice_same_block_but_different_course( mock_set_library_block_olx.assert_called_once() -@mock.patch('openedx.core.djangoapps.content_libraries.api.OAuthAPIClient') +@mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.OAuthAPIClient') class EdxApiImportClientTest(TestCase): """ Tests for EdxApiImportClient. @@ -195,11 +195,11 @@ def mock_oauth_client_response(self, mock_oauth_client, *, content=None, excepti return mock_response, mock_content return mock_response - @mock.patch('openedx.core.djangoapps.content_libraries.api.add_library_block_static_asset_file') - @mock.patch('openedx.core.djangoapps.content_libraries.api.create_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.get_library_block_static_asset_files') - @mock.patch('openedx.core.djangoapps.content_libraries.api.publish_changes') - @mock.patch('openedx.core.djangoapps.content_libraries.api.set_library_block_olx') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.add_library_block_static_asset_file') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.create_library_block') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.get_library_block_static_asset_files') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.publish_changes') + @mock.patch('openedx.core.djangoapps.content_libraries.api.libraries.set_library_block_olx') def test_import_block_when_url_is_from_studio( self, mock_set_library_block_olx, diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 7295e3bab0b5..bcb1573a27c0 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -150,10 +150,10 @@ def test_library_org_validation(self): self._create_library(slug="existing-org-1", title="Library in an existing org", org="CL-TEST") @patch( - "openedx.core.djangoapps.content_libraries.views.user_can_create_organizations", + "openedx.core.djangoapps.content_libraries.rest_api.libraries.user_can_create_organizations", ) @patch( - "openedx.core.djangoapps.content_libraries.views.get_allowed_organizations_for_libraries", + "openedx.core.djangoapps.content_libraries.rest_api.libraries.get_allowed_organizations_for_libraries", ) @override_settings(ORGANIZATIONS_AUTOCREATE=False) def test_library_org_no_autocreate(self, mock_get_allowed_organizations, mock_can_create_organizations): @@ -198,7 +198,10 @@ def test_library_org_no_autocreate(self, mock_get_allowed_organizations, mock_ca assert mock_get_allowed_organizations.call_count == 2 @skip("This endpoint shouldn't support num_blocks and has_unpublished_*.") - @patch("openedx.core.djangoapps.content_libraries.views.LibraryRootView.pagination_class.page_size", new=2) + @patch( + "openedx.core.djangoapps.content_libraries.rest_api.libraries.LibraryRootView.pagination_class.page_size", + new=2, + ) def test_list_library(self): """ Test the /libraries API and its pagination @@ -496,7 +499,10 @@ def test_library_blocks_studio_view(self): assert 'resources' in fragment assert 'Hello world!' in fragment['content'] - @patch("openedx.core.djangoapps.content_libraries.views.LibraryBlocksView.pagination_class.page_size", new=2) + @patch( + "openedx.core.djangoapps.content_libraries.rest_api.libraries.LibraryBlocksView.pagination_class.page_size", + new=2, + ) def test_list_library_blocks(self): """ Test the /libraries/{lib_key_str}/blocks API and its pagination diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 1272d79b3873..c4bffdeb431e 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -6,8 +6,7 @@ from rest_framework import routers -from . import views -from . import views_collections +from .rest_api import blocks, collections, libraries # Django application name. @@ -17,11 +16,11 @@ # Router for importing blocks from courseware. import_blocks_router = routers.DefaultRouter() -import_blocks_router.register(r'tasks', views.LibraryImportTaskViewSet, basename='import-block-task') +import_blocks_router.register(r'tasks', libraries.LibraryImportTaskViewSet, basename='import-block-task') library_collections_router = routers.DefaultRouter() library_collections_router.register( - r'collections', views_collections.LibraryCollectionsView, basename="library-collections" + r'collections', collections.LibraryCollectionsView, basename="library-collections" ) # These URLs are only used in Studio. The LMS already provides all the @@ -31,61 +30,61 @@ urlpatterns = [ path('api/libraries/v2/', include([ # list of libraries / create a library: - path('', views.LibraryRootView.as_view()), + path('', libraries.LibraryRootView.as_view()), path('/', include([ # get data about a library, update a library, or delete a library: - path('', views.LibraryDetailsView.as_view()), + path('', libraries.LibraryDetailsView.as_view()), # Get the list of XBlock types that can be added to this library - path('block_types/', views.LibraryBlockTypesView.as_view()), + path('block_types/', libraries.LibraryBlockTypesView.as_view()), # Get the list of XBlocks in this library, or add a new one: - path('blocks/', views.LibraryBlocksView.as_view()), - # Commit (POST) or revert (DELETE) all pending changes to this library: - path('commit/', views.LibraryCommitView.as_view()), + path('blocks/', blocks.LibraryBlocksView.as_view()), + # Publish (POST) or revert (DELETE) all pending changes to this library: + path('commit/', libraries.LibraryCommitView.as_view()), # Get the list of users/groups who have permission to view/edit/administer this library: - path('team/', views.LibraryTeamView.as_view()), + path('team/', libraries.LibraryTeamView.as_view()), # Add/Edit (PUT) or remove (DELETE) a user's permission to use this library - path('team/user//', views.LibraryTeamUserView.as_view()), + path('team/user//', libraries.LibraryTeamUserView.as_view()), # Add/Edit (PUT) or remove (DELETE) a group's permission to use this library - path('team/group//', views.LibraryTeamGroupView.as_view()), + path('team/group//', libraries.LibraryTeamGroupView.as_view()), # Import blocks into this library. path('import_blocks/', include(import_blocks_router.urls)), # Paste contents of clipboard into library - path('paste_clipboard/', views.LibraryPasteClipboardView.as_view()), + path('paste_clipboard/', libraries.LibraryPasteClipboardView.as_view()), # Library Collections path('', include(library_collections_router.urls)), ])), path('blocks//', include([ # Get metadata about a specific XBlock in this library, or delete the block: - path('', views.LibraryBlockView.as_view()), - path('restore/', views.LibraryBlockRestore.as_view()), + path('', blocks.LibraryBlockView.as_view()), + # Restore a soft-deleted block + path('restore/', blocks.LibraryBlockRestore.as_view()), # Update collections for a given component - path('collections/', views.LibraryBlockCollectionsView.as_view(), name='update-collections'), + path('collections/', blocks.LibraryBlockCollectionsView.as_view(), name='update-collections'), # Get the LTI URL of a specific XBlock - path('lti/', views.LibraryBlockLtiUrlView.as_view(), name='lti-url'), + path('lti/', blocks.LibraryBlockLtiUrlView.as_view(), name='lti-url'), # Get the OLX source code of the specified block: - path('olx/', views.LibraryBlockOlxView.as_view()), + path('olx/', blocks.LibraryBlockOlxView.as_view()), # CRUD for static asset files associated with a block in the library: - path('assets/', views.LibraryBlockAssetListView.as_view()), - path('assets/', views.LibraryBlockAssetView.as_view()), - path('publish/', views.LibraryBlockPublishView.as_view()), + path('assets/', blocks.LibraryBlockAssetListView.as_view()), + path('assets/', blocks.LibraryBlockAssetView.as_view()), + path('publish/', blocks.LibraryBlockPublishView.as_view()), # Future: discard changes for just this one block - # Future: set a block's tags (tags are stored in a Tag bundle and linked in) ])), re_path(r'^lti/1.3/', include([ - path('login/', views.LtiToolLoginView.as_view(), name='lti-login'), - path('launch/', views.LtiToolLaunchView.as_view(), name='lti-launch'), - path('pub/jwks/', views.LtiToolJwksView.as_view(), name='lti-pub-jwks'), + path('login/', libraries.LtiToolLoginView.as_view(), name='lti-login'), + path('launch/', libraries.LtiToolLaunchView.as_view(), name='lti-launch'), + path('pub/jwks/', libraries.LtiToolJwksView.as_view(), name='lti-pub-jwks'), ])), ])), path('library_assets/', include([ path( 'component_versions//', - views.LibraryComponentAssetView.as_view(), + blocks.LibraryComponentAssetView.as_view(), name='library-assets', ), path( 'blocks//', - views.LibraryComponentDraftAssetView.as_view(), + blocks.LibraryComponentDraftAssetView.as_view(), name='library-draft-assets', ), ]) From 9383d5fde808dc49ac87e98bd8dba3ea308b2d36 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 13 Mar 2025 19:35:02 -0700 Subject: [PATCH 07/25] feat: new REST API for units in content libraries --- .../content_libraries/api/__init__.py | 1 + .../content_libraries/api/containers.py | 113 ++++++++++++++++++ .../content_libraries/api/libraries.py | 25 +++- .../content_libraries/rest_api/containers.py | 58 +++++++++ .../content_libraries/rest_api/serializers.py | 40 +++++++ .../content_libraries/tests/base.py | 9 ++ .../tests/test_containers.py | 62 ++++++++++ .../core/djangoapps/content_libraries/urls.py | 12 +- 8 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 openedx/core/djangoapps/content_libraries/api/containers.py create mode 100644 openedx/core/djangoapps/content_libraries/rest_api/containers.py create mode 100644 openedx/core/djangoapps/content_libraries/tests/test_containers.py diff --git a/openedx/core/djangoapps/content_libraries/api/__init__.py b/openedx/core/djangoapps/content_libraries/api/__init__.py index ad97436fe3ac..d4d9fe047fb9 100644 --- a/openedx/core/djangoapps/content_libraries/api/__init__.py +++ b/openedx/core/djangoapps/content_libraries/api/__init__.py @@ -1,6 +1,7 @@ """ Python API for working with content libraries """ +from .containers import * from .libraries import * from .blocks import * from . import permissions diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py new file mode 100644 index 000000000000..10a38b065c19 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -0,0 +1,113 @@ +""" +API for containers (Sections, Subsections, Units) in Content Libraries +""" +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from uuid import uuid4 + +from django.utils.text import slugify +from opaque_keys.edx.locator import ( + LibraryLocatorV2, + LibraryContainerLocator, +) + +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api import authoring_models + +from ..models import ContentLibrary +from .libraries import PublishableItem + +# The public API is only the following symbols: +__all__ = [ + "ContainerMetadata", + "create_container", +] + + +class ContainerType(Enum): + Unit = "unit" + + +@dataclass(frozen=True, kw_only=True) +class ContainerMetadata(PublishableItem): + """ + Class that represents the metadata about an XBlock in a content library. + """ + container_key: LibraryContainerLocator + container_type: ContainerType + + @classmethod + def from_container(cls, library_key, container: authoring_models.Container, associated_collections=None): + """ + Construct a LibraryXBlockMetadata from a Component object. + """ + last_publish_log = container.versioning.last_publish_log + + assert container.unit is not None + container_type = ContainerType.Unit + + published_by = None + if last_publish_log and last_publish_log.published_by: + published_by = last_publish_log.published_by.username + + draft = container.versioning.draft + published = container.versioning.published + last_draft_created = draft.created if draft else None + last_draft_created_by = draft.publishable_entity_version.created_by.username if draft else "" + + return cls( + container_key=LibraryContainerLocator( + library_key, + container_type=container_type.value, + container_id=container.publishable_entity.key, + ), + container_type=container_type, + display_name=draft.title, + created=container.created, + modified=draft.created, + draft_version_num=draft.version_num, + published_version_num=published.version_num if published else None, + last_published=None if last_publish_log is None else last_publish_log.published_at, + published_by=published_by or "", + last_draft_created=last_draft_created, + last_draft_created_by=last_draft_created_by, + has_unpublished_changes=authoring_api.contains_unpublished_changes(container.pk), + collections=associated_collections or [], + ) + + +def create_container( + library_key: LibraryLocatorV2, + container_type: ContainerType, + slug: str | None, + title: str, + user_id: int | None, +) -> ContainerMetadata: + """ + Create a container (e.g. a Unit) in the specified content library. + + It will initially be empty. + """ + assert isinstance(library_key, LibraryLocatorV2) + content_library = ContentLibrary.objects.get_by_key(library_key) + assert content_library.learning_package_id # Should never happen but we made this a nullable field so need to check + if slug is None: + # Automatically generate a slug. Append a random suffix so it should be unique. + slug = slugify(title, allow_unicode=True) + '-' + uuid4().hex[-6:] + # Make sure the slug is valid by first creating a key for the new container: + LibraryContainerLocator(library_key=library_key, container_type=container_type.value, container_id=slug) + # Then try creating the actual container: + match container_type: + case ContainerType.Unit: + container, _initial_version = authoring_api.create_unit_and_version( + content_library.learning_package_id, + key=slug, + title=title, + created=datetime.now(), + created_by=user_id, + ) + case _: + raise ValueError(f"Invalid container type: {container_type}") + return ContainerMetadata.from_container(library_key, container) diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index c89c573fd5a1..13d41921e860 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -277,18 +277,25 @@ class CollectionMetadata: @dataclass(frozen=True) -class LibraryXBlockMetadata: +class LibraryItem: """ - Class that represents the metadata about an XBlock in a content library. + Common fields for anything that can be found in a content library. """ - usage_key: LibraryUsageLocatorV2 created: datetime modified: datetime + display_name: str + + +@dataclass(frozen=True, kw_only=True) +class PublishableItem(LibraryItem): + """ + Common fields for anything that can be found in a content library that has + draft/publish support. + """ draft_version_num: int published_version_num: int | None = None - display_name: str = "" last_published: datetime | None = None - # THe username of the user who last published this. + # The username of the user who last published this. published_by: str = "" last_draft_created: datetime | None = None # The username of the user who created the last draft. @@ -296,6 +303,14 @@ class LibraryXBlockMetadata: has_unpublished_changes: bool = False collections: list[CollectionMetadata] = field(default_factory=list) + +@dataclass(frozen=True, kw_only=True) +class LibraryXBlockMetadata(PublishableItem): + """ + Class that represents the metadata about an XBlock in a content library. + """ + usage_key: LibraryUsageLocatorV2 + @classmethod def from_component(cls, library_key, component, associated_collections=None): """ diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py new file mode 100644 index 000000000000..5440cbe6b1ec --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -0,0 +1,58 @@ +""" +REST API views for containers (sections, subsections, units) in content libraries +""" +from __future__ import annotations + +import logging + +from django.contrib.auth import get_user_model +from django.db.transaction import non_atomic_requests +from django.utils.decorators import method_decorator +from django.utils.translation import gettext as _ +from drf_yasg.utils import swagger_auto_schema + +from opaque_keys.edx.locator import LibraryLocatorV2 +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response + +from openedx.core.djangoapps.content_libraries import api, permissions +from openedx.core.lib.api.view_utils import view_auth_classes +from . import serializers +from .utils import convert_exceptions + +User = get_user_model() +log = logging.getLogger(__name__) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainersView(GenericAPIView): + """ + Views to work with Containers in a specific content library. + """ + serializer_class = serializers.LibraryContainerMetadataSerializer + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.LibraryContainerMetadataSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def post(self, request, lib_key_str): + """ + Create a new Container in this content library + """ + library_key = LibraryLocatorV2.from_string(lib_key_str) + api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) + serializer = serializers.LibraryContainerMetadataSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + container_type = serializer.validated_data['container_type'] + container = api.create_container( + library_key, + container_type, + title=serializer.validated_data['display_name'], + slug=serializer.validated_data.get('slug'), + user_id=request.user.id, + ) + + return Response(serializers.LibraryContainerMetadataSerializer(container).data) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index c902e375870b..ee1f9d37d1df 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -10,6 +10,7 @@ from opaque_keys import InvalidKeyError from openedx_learning.api.authoring_models import Collection +from openedx.core.djangoapps.content_libraries.api.containers import ContainerMetadata, ContainerType from openedx.core.djangoapps.content_libraries.constants import ( ALL_RIGHTS_RESERVED, LICENSE_OPTIONS, @@ -230,6 +231,45 @@ class LibraryXBlockStaticFilesSerializer(serializers.Serializer): files = LibraryXBlockStaticFileSerializer(many=True) +class LibraryContainerMetadataSerializer(serializers.Serializer): + """ + Serializer for Containers like Sections, Subsections, Units + + Converts from ContainerMetadata to JSON-compatible data + """ + container_key = serializers.CharField(read_only=True) + container_type = serializers.CharField() + display_name = serializers.CharField() + last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + published_by = serializers.CharField(read_only=True) + last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + 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) + modified = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + tags_count = serializers.IntegerField(read_only=True) + collections = CollectionMetadataSerializer(many=True, required=False, read_only=True) + + # When creating a new container in a library, the slug becomes the ID part of + # the definition key and usage key: + slug = serializers.CharField(write_only=True) + + def to_representation(self, instance: ContainerMetadata): + """ Convert to JSON-serializable data types """ + data = super().to_representation(instance) + data["container_type"] = instance.container_type.value # Force to a string, not an enum value instance + return data + + def to_internal_value(self, data): + """ + Convert JSON-ish data back to native python types. + Returns a dictionary, not a ContainerMetadata instance. + """ + result = super().to_internal_value(data) + result["container_type"] = ContainerType(data["container_type"]) + return result + + class ContentLibraryBlockImportTaskSerializer(serializers.ModelSerializer): """ Serializer for a Content Library block import task. diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 77de1c028aa7..639bdcd8cef1 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -22,6 +22,7 @@ URL_LIB_LINKS = URL_LIB_DETAIL + 'links/' # Get the list of links in this library, or add a new one URL_LIB_COMMIT = URL_LIB_DETAIL + 'commit/' # Commit (POST) or revert (DELETE) all pending changes to this library URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this library, or add a new one +URL_LIB_CONTAINERS = URL_LIB_DETAIL + 'containers/' # Create a new container in this library URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authorized to use this library URL_LIB_TEAM_USER = URL_LIB_TEAM + 'user/{username}/' # Add/edit/remove a user's permission to use this library URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library @@ -350,3 +351,11 @@ def _get_library_block_fields(self, block_key, version=None, expect_response=200 def _set_library_block_fields(self, block_key, new_fields, expect_response=200): """ Set the fields of a specific block in the library. This API is only used by the MFE editors. """ return self._api('post', URL_BLOCK_FIELDS_URL.format(block_key=block_key), new_fields, expect_response) + + def _create_container(self, lib_key, container_type, slug: str | None, display_name: str, expect_response=200): + """ Create a container (unit etc.) """ + return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), { + "container_type": container_type, + "slug": slug, + "display_name": display_name, + }, expect_response) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py new file mode 100644 index 000000000000..feac6ea750df --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -0,0 +1,62 @@ +""" +Tests for Learning-Core-based Content Libraries +""" +from datetime import datetime, timezone + +import ddt +from freezegun import freeze_time + +from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangolib.testing.utils import skip_unless_cms + + +@skip_unless_cms +@ddt.ddt +class ContainersTestCase(ContentLibrariesRestApiTest): + """ + Tests for containers (Sections, Subsections, Units) in Content Libraries. + + These tests use the REST API, which in turn relies on the Python API. + Some tests may use the python API directly if necessary to provide + coverage of any code paths not accessible via the REST API. + + In general, these tests should + (1) Use public APIs only - don't directly create data using other methods, + which results in a less realistic test and ties the test suite too + closely to specific implementation details. + (Exception: users can be provisioned using a user factory) + (2) Assert that fields are present in responses, but don't assert that the + entire response has some specific shape. That way, things like adding + new fields to an API response, which are backwards compatible, won't + break any tests, but backwards-incompatible API changes will. + """ + # Note: if we need events at some point, add OpenEdxEventsTestMixin and set the list here; see other test suites. + # ENABLED_OPENEDX_EVENTS = [] + + def test_unit_crud(self): + """ + Test Create, Read, Update, and Delete of a Unit + """ + lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") + + # Create a unit: + create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc) + with freeze_time(create_date): + container_data = self._create_container(lib["id"], "unit", slug="u1", display_name="Test Unit") + expected_data = { + "container_key": "lct:CL-TEST:containers:unit:u1", + "container_type": "unit", + "display_name": "Test Unit", + "last_published": None, + "published_by": "", + "last_draft_created": "2024-09-08T07:06:05Z", + "last_draft_created_by": 'Bob', + 'has_unpublished_changes': True, + 'created': '2024-09-08T07:06:05Z', + 'modified': '2024-09-08T07:06:05Z', + 'collections': [], + } + + self.assertDictContainsEntries(container_data, expected_data) + + # TODO: test that a regular user with read-only permissions on the library cannot create units diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index c4bffdeb431e..b5e1805f2264 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -6,7 +6,7 @@ from rest_framework import routers -from .rest_api import blocks, collections, libraries +from .rest_api import blocks, collections, containers, libraries # Django application name. @@ -38,6 +38,8 @@ path('block_types/', libraries.LibraryBlockTypesView.as_view()), # Get the list of XBlocks in this library, or add a new one: path('blocks/', blocks.LibraryBlocksView.as_view()), + # Add a new container (unit etc.) to this library: + path('containers/', containers.LibraryContainersView.as_view()), # Publish (POST) or revert (DELETE) all pending changes to this library: path('commit/', libraries.LibraryCommitView.as_view()), # Get the list of users/groups who have permission to view/edit/administer this library: @@ -70,6 +72,14 @@ path('publish/', blocks.LibraryBlockPublishView.as_view()), # Future: discard changes for just this one block ])), + # Containers are Sections, Subsections, and Units + path('containers//', include([ + # Get metadata about a specific container in this library, or delete the container: + # path('', views.LibraryContainerView.as_view()), + # Update collections for a given container + # path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'), + # path('publish/', views.LibraryContainerPublishView.as_view()), + ])), re_path(r'^lti/1.3/', include([ path('login/', libraries.LtiToolLoginView.as_view(), name='lti-login'), path('launch/', libraries.LtiToolLaunchView.as_view(), name='lti-launch'), From 60169dbfd5fc208911f3709b07ef78cb44f0cc0c Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 14 Mar 2025 15:38:09 -0700 Subject: [PATCH 08/25] feat: python+REST API to get a unit --- .../content_libraries/api/containers.py | 21 +++++++++++++++ .../content_libraries/rest_api/containers.py | 27 ++++++++++++++++++- .../rest_api/url_converters.py | 23 ++++++++++++++++ .../content_libraries/tests/base.py | 5 ++++ .../tests/test_containers.py | 5 ++++ .../core/djangoapps/content_libraries/urls.py | 12 ++++++--- 6 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 openedx/core/djangoapps/content_libraries/rest_api/url_converters.py diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 10a38b065c19..c636a2e8edee 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -22,6 +22,7 @@ # The public API is only the following symbols: __all__ = [ "ContainerMetadata", + "get_container", "create_container", ] @@ -78,6 +79,26 @@ def from_container(cls, library_key, container: authoring_models.Container, asso ) +def get_container(container_key: LibraryContainerLocator) -> ContainerMetadata: + """ + Get a container (a Section, Subsection, or Unit). + """ + assert isinstance(container_key, LibraryContainerLocator) + content_library = ContentLibrary.objects.get_by_key(container_key.library_key) + learning_package = content_library.learning_package + assert learning_package is not None + # FIXME: we need an API to get container by key, without needing to get the publishable entity ID first + container_pe = authoring_api.get_publishable_entity_by_key( + learning_package, + key=container_key.container_id, + ) + container = authoring_api.get_container(container_pe.id) + # ^ Should be just authoring_api.get_container_by_key(learning_package.id, container_key.container_id) + container_meta = ContainerMetadata.from_container(container_key.library_key, container) + assert container_meta.container_type.value == container_key.container_type + return container_meta + + def create_container( library_key: LibraryLocatorV2, container_type: ContainerType, diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index 5440cbe6b1ec..fd96315391cd 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext as _ from drf_yasg.utils import swagger_auto_schema -from opaque_keys.edx.locator import LibraryLocatorV2 +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator from rest_framework.generics import GenericAPIView from rest_framework.response import Response @@ -56,3 +56,28 @@ def post(self, request, lib_key_str): ) return Response(serializers.LibraryContainerMetadataSerializer(container).data) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerView(GenericAPIView): + """ + View to get data about a specific container (a section, subsection, or unit) + """ + serializer_class = serializers.LibraryContainerMetadataSerializer + + @convert_exceptions + @swagger_auto_schema( + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def get(self, request, container_key: LibraryContainerLocator): + """ + Get information about a container + """ + api.require_permission_for_library_key( + container_key.library_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + container = api.get_container(container_key) + return Response(serializers.LibraryContainerMetadataSerializer(container).data) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/url_converters.py b/openedx/core/djangoapps/content_libraries/rest_api/url_converters.py new file mode 100644 index 000000000000..c8c4b1c6ebda --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/rest_api/url_converters.py @@ -0,0 +1,23 @@ +""" +URL pattern converters +https://docs.djangoproject.com/en/5.1/topics/http/urls/#registering-custom-path-converters +""" +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import LibraryContainerLocator + + +class LibraryContainerLocatorConverter: + """ + Converter that matches library container IDs like: + lct:CL-TEST:containers:unit:u1 + """ + regex = r'[\w-]+(:[\w\-.]+)+' + + def to_python(self, value: str) -> LibraryContainerLocator: + try: + return LibraryContainerLocator.from_string(value) + except InvalidKeyError as exc: + raise ValueError from exc + + def to_url(self, value: LibraryContainerLocator) -> str: + return str(value) diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 639bdcd8cef1..0fd85f41f5b8 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -32,6 +32,7 @@ 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 +URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/' URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/' @@ -359,3 +360,7 @@ def _create_container(self, lib_key, container_type, slug: str | None, display_n "slug": slug, "display_name": display_name, }, expect_response) + + def _get_container(self, container_key: str, expect_response=200): + """ Get a container (unit etc.) """ + return self._api('get', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index feac6ea750df..40dfdcc064a8 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -59,4 +59,9 @@ def test_unit_crud(self): self.assertDictContainsEntries(container_data, expected_data) + # Fetch the unit: + unit_as_read = self._get_container(container_data["container_key"]) + # make sure it contains the same data when we read it back: + self.assertDictContainsEntries(unit_as_read, expected_data) + # TODO: test that a regular user with read-only permissions on the library cannot create units diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index b5e1805f2264..c3e7b8130057 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -2,17 +2,21 @@ URL configuration for Studio's Content Libraries REST API """ -from django.urls import include, path, re_path +from django.urls import include, path, re_path, register_converter from rest_framework import routers -from .rest_api import blocks, collections, containers, libraries +from .rest_api import blocks, collections, containers, libraries, url_converters # Django application name. app_name = 'openedx.core.djangoapps.content_libraries' +# URL converters + +register_converter(url_converters.LibraryContainerLocatorConverter, "lib_container_key") + # Router for importing blocks from courseware. import_blocks_router = routers.DefaultRouter() @@ -73,9 +77,9 @@ # Future: discard changes for just this one block ])), # Containers are Sections, Subsections, and Units - path('containers//', include([ + path('containers//', include([ # Get metadata about a specific container in this library, or delete the container: - # path('', views.LibraryContainerView.as_view()), + path('', containers.LibraryContainerView.as_view()), # Update collections for a given container # path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'), # path('publish/', views.LibraryContainerPublishView.as_view()), From 6a9a916131b43d7ffc8df640694c1b484db506bd Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 14 Mar 2025 15:48:22 -0700 Subject: [PATCH 09/25] feat: auto-generate slug/key/ID from title of units --- .../content_libraries/rest_api/serializers.py | 2 +- .../djangoapps/content_libraries/tests/base.py | 9 ++++----- .../content_libraries/tests/test_containers.py | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index ee1f9d37d1df..cac8fddd79ad 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -252,7 +252,7 @@ class LibraryContainerMetadataSerializer(serializers.Serializer): # When creating a new container in a library, the slug becomes the ID part of # the definition key and usage key: - slug = serializers.CharField(write_only=True) + slug = serializers.CharField(write_only=True, required=False) def to_representation(self, instance: ContainerMetadata): """ Convert to JSON-serializable data types """ diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 0fd85f41f5b8..e4b7b92e088f 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -355,11 +355,10 @@ def _set_library_block_fields(self, block_key, new_fields, expect_response=200): def _create_container(self, lib_key, container_type, slug: str | None, display_name: str, expect_response=200): """ Create a container (unit etc.) """ - return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), { - "container_type": container_type, - "slug": slug, - "display_name": display_name, - }, expect_response) + data = {"container_type": container_type, "display_name": display_name} + if slug: + data["slug"] = slug + return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), data, expect_response) def _get_container(self, container_key: str, expect_response=200): """ Get a container (unit etc.) """ diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 40dfdcc064a8..dfad2ab0616b 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -65,3 +65,18 @@ def test_unit_crud(self): self.assertDictContainsEntries(unit_as_read, expected_data) # TODO: test that a regular user with read-only permissions on the library cannot create units + + def test_unit_gets_auto_slugs(self): + """ + Test that we can create units by specifying only a title, and they get + unique slugs assigned automatically. + """ + lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") + + # Create two units, specifying their titles but not their slugs/keys: + container1_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None) + container2_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None) + # Notice the container IDs below are slugified from the title: "alpha-bravo-NNNNN" + assert container1_data["container_key"].startswith("lct:CL-TEST:containers:unit:alpha-bravo-") + assert container2_data["container_key"].startswith("lct:CL-TEST:containers:unit:alpha-bravo-") + assert container1_data["container_key"] != container2_data["container_key"] From 0352559616d9c7938682e743a5358309e48f1fdc Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 14 Mar 2025 16:40:49 -0700 Subject: [PATCH 10/25] feat: generate search index documents for containers --- .../djangoapps/content/search/documents.py | 56 ++++++++++++++++++- .../content/search/tests/test_documents.py | 36 ++++++++++++ .../content_libraries/api/containers.py | 35 ++++++++++-- .../content_libraries/api/libraries.py | 3 + 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 3d739781fe71..6e650af263f1 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -10,7 +10,7 @@ from django.core.exceptions import ObjectDoesNotExist from opaque_keys.edx.keys import LearningContextKey, UsageKey from openedx_learning.api import authoring as authoring_api -from opaque_keys.edx.locator import LibraryLocatorV2 +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator from rest_framework.exceptions import NotFound from openedx.core.djangoapps.content.search.models import SearchAccess @@ -100,6 +100,7 @@ class DocType: """ course_block = "course_block" library_block = "library_block" + library_container = "library_container" collection = "collection" @@ -546,3 +547,56 @@ def searchable_doc_for_collection( doc['_disabled'] = True return doc + + +def searchable_doc_for_container( + container_key: LibraryContainerLocator, +) -> dict: + """ + Generate a dictionary document suitable for ingestion into a search engine + like Meilisearch or Elasticsearch, so that the given collection can be + found using faceted search. + + If no container is found for the given container key, the returned document + will contain only basic information derived from the container key, and no + Fields.type value will be included in the returned dict. + """ + doc = { + Fields.id: meili_id_from_opaque_key(container_key), + Fields.context_key: str(container_key.library_key), + Fields.org: str(container_key.library_key.org), + # In the future, this may be either course_container or library_container + Fields.type: DocType.library_container, + Fields.usage_key: str(container_key), # Field name isn't exact but this is the closest match + Fields.block_id: container_key.container_id, # Field name isn't exact but this is the closest match + Fields.access_id: _meili_access_id_from_context_key(container_key.library_key), + } + + try: + container = lib_api.get_container(container_key) + except lib_api.ContentLibraryCollectionNotFound: + # Container not found, so we can only return the base doc + pass + + if container: + # TODO: check if there's a more efficient way to load these num_children counts? + draft_num_children = len(lib_api.get_container_children(container_key, published=False)) + + doc.update({ + Fields.display_name: container.display_name, + Fields.created: container.created.timestamp(), + Fields.modified: container.modified.timestamp(), + Fields.num_children: draft_num_children, + }) + library = lib_api.get_library(container_key.library_key) + if library: + doc[Fields.breadcrumbs] = [{"display_name": library.title}] + + if container.published_version_num is not None: + published_num_children = len(lib_api.get_container_children(container_key, published=True)) + doc[Fields.published] = { + # Fields.published_display_name: container_published.title, TODO: set the published title + Fields.published_num_children: published_num_children, + } + + return doc diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 3bab2795b9f5..20b7ec88585b 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -22,6 +22,7 @@ searchable_doc_tags_for_collection, searchable_doc_collections, searchable_doc_for_collection, + searchable_doc_for_container, searchable_doc_for_library_block, ) from ..models import SearchAccess @@ -30,6 +31,7 @@ searchable_doc_tags = lambda x: x searchable_doc_tags_for_collection = lambda x: x searchable_doc_for_collection = lambda x: x + searchable_doc_for_container = lambda x: x searchable_doc_for_library_block = lambda x: x SearchAccess = {} @@ -494,6 +496,40 @@ def test_collection_with_published_library(self): } } + def test_draft_container(self): + """ + Test creating a search document for a draft-only container + """ + created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(created_date): + container_meta = library_api.create_container( + self.library.key, + container_type=library_api.ContainerType.Unit, + slug="unit1", + title="A Unit in the Search Index", + user_id=None, + ) + + doc = searchable_doc_for_container(container_meta.container_key) + + assert doc == { + "id": "lctedx2012_fallunitunit1-edd13a0c", + "block_id": "unit1", + "usage_key": "lct:edX:2012_Fall:unit:unit1", + "type": "library_container", + "org": "edX", + "display_name": "A Unit in the Search Index", + # description is not set for containers + "num_children": 0, + "context_key": "lib:edX:2012_Fall", + "access_id": self.library_access_id, + "breadcrumbs": [{"display_name": "some content_library"}], + "created": 1680674828.0, + "modified": 1680674828.0, + # "tags" should be here but we haven't implemented them yet + # "published" is not set since we haven't published it yet + } + def test_mathjax_plain_text_conversion_for_search(self): """ Test how an HTML block with mathjax equations gets converted to plain text in search description. diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index c636a2e8edee..b9d033777aaa 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -22,8 +22,10 @@ # The public API is only the following symbols: __all__ = [ "ContainerMetadata", + "ContainerType", "get_container", "create_container", + "get_container_children", ] @@ -34,7 +36,7 @@ class ContainerType(Enum): @dataclass(frozen=True, kw_only=True) class ContainerMetadata(PublishableItem): """ - Class that represents the metadata about an XBlock in a content library. + Class that represents the metadata about a Container (e.g. Unit) in a content library. """ container_key: LibraryContainerLocator container_type: ContainerType @@ -49,14 +51,17 @@ def from_container(cls, library_key, container: authoring_models.Container, asso assert container.unit is not None container_type = ContainerType.Unit - published_by = None + published_by = "" if last_publish_log and last_publish_log.published_by: published_by = last_publish_log.published_by.username draft = container.versioning.draft published = container.versioning.published last_draft_created = draft.created if draft else None - last_draft_created_by = draft.publishable_entity_version.created_by.username if draft else "" + if draft and draft.publishable_entity_version.created_by: + last_draft_created_by = draft.publishable_entity_version.created_by.username + else: + last_draft_created_by = "" return cls( container_key=LibraryContainerLocator( @@ -71,7 +76,7 @@ def from_container(cls, library_key, container: authoring_models.Container, asso draft_version_num=draft.version_num, published_version_num=published.version_num if published else None, last_published=None if last_publish_log is None else last_publish_log.published_at, - published_by=published_by or "", + published_by=published_by, last_draft_created=last_draft_created, last_draft_created_by=last_draft_created_by, has_unpublished_changes=authoring_api.contains_unpublished_changes(container.pk), @@ -132,3 +137,25 @@ def create_container( case _: raise ValueError(f"Invalid container type: {container_type}") return ContainerMetadata.from_container(library_key, container) + + +def get_container_children( + container_key: LibraryContainerLocator, + published=False, +) -> list[authoring_api.ContainerEntityListEntry]: + """ + Get the entities contained in the given container (e.g. the components/xblocks in a unit) + """ + assert isinstance(container_key, LibraryContainerLocator) + content_library = ContentLibrary.objects.get_by_key(container_key.library_key) + learning_package = content_library.learning_package + assert learning_package is not None + # FIXME: we need an API to get container by key, without needing to get the publishable entity ID first + container_pe = authoring_api.get_publishable_entity_by_key( + learning_package, + key=container_key.container_id, + ) + assert container_pe.container is not None + child_entities = authoring_api.get_entities_in_container(container_pe.container, published=published) + # TODO: convert the return type to list[ContainerMetadata | LibraryXBlockMetadata] ? + return child_entities diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index 13d41921e860..545b4037c43c 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -97,6 +97,7 @@ from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import ( Collection, + Container, Component, ComponentVersion, MediaType, @@ -127,6 +128,7 @@ # Exceptions - maybe move them to a new file? "ContentLibraryNotFound", "ContentLibraryCollectionNotFound", + "ContentLibraryContainerNotFound", "ContentLibraryBlockNotFound", "LibraryAlreadyExists", "LibraryCollectionAlreadyExists", @@ -179,6 +181,7 @@ ContentLibraryCollectionNotFound = Collection.DoesNotExist +ContentLibraryContainerNotFound = Container.DoesNotExist class ContentLibraryBlockNotFound(XBlockNotFoundError): """ XBlock not found in the content library """ From cf96c6f97eac6dc627c3ca18f9f14ad4bc900570 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Sun, 16 Mar 2025 09:02:34 +1030 Subject: [PATCH 11/25] refactor: rename LibraryElementKey to LibraryItemKey --- openedx/core/djangoapps/content_tagging/api.py | 4 ++-- openedx/core/djangoapps/content_tagging/types.py | 4 ++-- openedx/core/djangoapps/content_tagging/utils.py | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py index 8a11d8d1cb6b..9163ebd58aa0 100644 --- a/openedx/core/djangoapps/content_tagging/api.py +++ b/openedx/core/djangoapps/content_tagging/api.py @@ -12,7 +12,7 @@ import openedx_tagging.core.tagging.api as oel_tagging from django.db.models import Exists, OuterRef, Q, QuerySet from django.utils.timezone import now -from opaque_keys.edx.keys import CourseKey, LibraryElementKey +from opaque_keys.edx.keys import CourseKey, LibraryItemKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy from openedx_tagging.core.tagging.models.utils import TAGS_CSV_SEPARATOR @@ -230,7 +230,7 @@ def generate_csv_rows(object_id, buffer) -> Iterator[str]: """ content_key = get_content_key_from_string(object_id) - if isinstance(content_key, (UsageKey, LibraryElementKey)): + if isinstance(content_key, (UsageKey, LibraryItemKey)): raise ValueError("The object_id must be a CourseKey or a LibraryLocatorV2.") all_object_tags, taxonomies = get_all_object_tags(content_key) diff --git a/openedx/core/djangoapps/content_tagging/types.py b/openedx/core/djangoapps/content_tagging/types.py index c630a8bff107..3684e5705376 100644 --- a/openedx/core/djangoapps/content_tagging/types.py +++ b/openedx/core/djangoapps/content_tagging/types.py @@ -5,11 +5,11 @@ from typing import Dict, List, Union -from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryElementKey +from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryItemKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import Taxonomy -ContentKey = Union[LibraryLocatorV2, CourseKey, UsageKey, LibraryElementKey] +ContentKey = Union[LibraryLocatorV2, CourseKey, UsageKey, LibraryItemKey] ContextKey = Union[LibraryLocatorV2, CourseKey] TagValuesByTaxonomyIdDict = Dict[int, List[str]] diff --git a/openedx/core/djangoapps/content_tagging/utils.py b/openedx/core/djangoapps/content_tagging/utils.py index 7373e5190ae1..419b960927fd 100644 --- a/openedx/core/djangoapps/content_tagging/utils.py +++ b/openedx/core/djangoapps/content_tagging/utils.py @@ -5,7 +5,7 @@ from edx_django_utils.cache import RequestCache from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryElementKey +from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryItemKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import Taxonomy from organizations.models import Organization @@ -28,11 +28,11 @@ def get_content_key_from_string(key_str: str) -> ContentKey: return UsageKey.from_string(key_str) except InvalidKeyError: try: - return LibraryElementKey.from_string(key_str) + return LibraryItemKey.from_string(key_str) except InvalidKeyError as usage_key_error: raise ValueError( "object_id must be one of the following " - "keys: CourseKey, LibraryLocatorV2, UsageKey or LibraryElementKey" + "keys: CourseKey, LibraryLocatorV2, UsageKey or LibraryItemKey" ) from usage_key_error @@ -44,8 +44,8 @@ def get_context_key_from_key(content_key: ContentKey) -> ContextKey: if isinstance(content_key, (CourseKey, LibraryLocatorV2)): return content_key - # If the content key is a LibraryElementKey, return the LibraryLocatorV2 - if isinstance(content_key, LibraryElementKey): + # If the content key is a LibraryItemKey, return the LibraryLocatorV2 + if isinstance(content_key, LibraryItemKey): return content_key.library_key # If the content key is a UsageKey, return the context key From 9320592b220775a33c7132d2b4f8445ef029eefc Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Sun, 16 Mar 2025 09:17:52 +1030 Subject: [PATCH 12/25] fix: lint error --- openedx/core/djangoapps/content_libraries/api/libraries.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index 545b4037c43c..fd89ceb0d3fc 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -183,6 +183,7 @@ ContentLibraryContainerNotFound = Container.DoesNotExist + class ContentLibraryBlockNotFound(XBlockNotFoundError): """ XBlock not found in the content library """ From 2468ad017d09117bc90436b4f1d43ee712ed02c4 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Sun, 16 Mar 2025 15:46:18 +1030 Subject: [PATCH 13/25] feat: adds new units to search index on create/update and when running reindex_studio. Updates requirements for openedx-events and openedx-learning to support these changes. --- openedx/core/djangoapps/content/search/api.py | 70 +++++++++++++++++++ .../djangoapps/content/search/handlers.py | 33 +++++++++ .../core/djangoapps/content/search/tasks.py | 13 ++++ .../content/search/tests/test_api.py | 58 +++++++++++++-- .../content_libraries/api/containers.py | 35 ++++++---- .../tests/test_containers.py | 29 +++++++- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 4 +- requirements/edx/development.txt | 4 +- requirements/edx/doc.txt | 4 +- requirements/edx/testing.txt | 4 +- 11 files changed, 227 insertions(+), 29 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index dd8cfcdc891d..e2f8379ddc84 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -40,6 +40,7 @@ meili_id_from_opaque_key, searchable_doc_for_course_block, searchable_doc_for_collection, + searchable_doc_for_container, searchable_doc_for_library_block, searchable_doc_for_usage_key, searchable_doc_collections, @@ -475,6 +476,31 @@ def index_collection_batch(batch, num_done, library_key) -> int: status_cb(f"Error indexing collection batch {p}: {err}") return num_done + ############## Containers ############## + def index_container_batch(batch, num_done, library_key) -> int: + docs = [] + for container in batch: + try: + container_metadata = lib_api.ContainerMetadata.from_container( + library_key, + container, + ) + doc = searchable_doc_for_container(container_metadata.container_key) + # TODO: when we add container tags + # doc.update(searchable_doc_tags_for_container(library_key, container.key)) + docs.append(doc) + except Exception as err: # pylint: disable=broad-except + status_cb(f"Error indexing container {container.key}: {err}") + num_done += 1 + + if docs: + try: + # Add docs in batch of 100 at once (usually faster than adding one at a time): + _wait_for_meili_task(client.index(index_name).add_documents(docs)) + except (TypeError, KeyError, MeilisearchError) as err: + status_cb(f"Error indexing container batch {p}: {err}") + return num_done + for lib_key in lib_keys: status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing blocks in library {lib_key}") lib_docs = index_library(lib_key) @@ -497,6 +523,22 @@ def index_collection_batch(batch, num_done, library_key) -> int: IncrementalIndexCompleted.objects.get_or_create(context_key=lib_key) status_cb(f"{num_collections_done}/{num_collections} collections indexed for library {lib_key}") + # Similarly, batch process Units in pages of 100 + units = authoring_api.get_units(library.learning_package_id) + num_units = units.count() + num_units_done = 0 + status_cb(f"{num_units_done}/{num_units}. Now indexing units in library {lib_key}") + paginator = Paginator(units, 100) + for p in paginator.page_range: + num_units_done = index_container_batch( + paginator.page(p).object_list, + num_units_done, + lib_key, + ) + status_cb(f"{num_units_done}/{num_units} units indexed for library {lib_key}") + if incremental: + IncrementalIndexCompleted.objects.get_or_create(context_key=lib_key) + num_contexts_done += 1 ############## Courses ############## @@ -732,6 +774,34 @@ def update_library_components_collections( _update_index_docs(docs) +def upsert_library_container_index_doc(library_key: LibraryLocatorV2, container_key: str) -> None: + """ + Creates, updates, or deletes the document for the given Library Container in the search index. + + TODO: add support for indexing a container's components, like upsert_library_collection_index_doc does. + """ + container_metadata = lib_api.ContainerMetadata.from_container( + library_key, + container_key, + ) + doc = searchable_doc_for_container(container_metadata.container_key) + + # Soft-deleted/disabled containers are removed from the index + # and their components updated. + if doc.get('_disabled'): + + _delete_index_doc(doc[Fields.id]) + + # Hard-deleted containers are also deleted from the index + elif not doc.get(Fields.type): + + _delete_index_doc(doc[Fields.id]) + + # Otherwise, upsert the container. + else: + _update_index_docs([doc]) + + def upsert_content_library_index_docs(library_key: LibraryLocatorV2) -> None: """ Creates or updates the documents for the given Content Library in the search index diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py index 24add6748d7d..4565165e87ea 100644 --- a/openedx/core/djangoapps/content/search/handlers.py +++ b/openedx/core/djangoapps/content/search/handlers.py @@ -14,6 +14,7 @@ ContentObjectChangedData, LibraryBlockData, LibraryCollectionData, + LibraryContainerData, XBlockData, ) from openedx_events.content_authoring.signals import ( @@ -25,6 +26,9 @@ LIBRARY_COLLECTION_CREATED, LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, XBLOCK_CREATED, XBLOCK_DELETED, XBLOCK_UPDATED, @@ -45,6 +49,7 @@ delete_xblock_index_doc, update_content_library_index_docs, update_library_collection_index_doc, + update_library_container_index_doc, upsert_library_block_index_doc, upsert_xblock_index_doc, ) @@ -225,3 +230,31 @@ def content_object_associations_changed_handler(**kwargs) -> None: upsert_block_tags_index_docs(usage_key) if not content_object.changes or "collections" in content_object.changes: upsert_block_collections_index_docs(usage_key) + + +@receiver(LIBRARY_CONTAINER_CREATED) +@receiver(LIBRARY_CONTAINER_DELETED) +@receiver(LIBRARY_CONTAINER_UPDATED) +@only_if_meilisearch_enabled +def library_container_updated_handler(**kwargs) -> None: + """ + Create or update the index for the content library container + """ + library_container = kwargs.get("library_container", None) + if not library_container or not isinstance(library_container, LibraryContainerData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + + if library_container.background: + update_library_container_index_doc.delay( + str(library_container.library_key), + library_container.container_key, + ) + else: + # Update container index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches index. + # See content_library_updated_handler for more details. + update_library_container_index_doc.apply(args=[ + str(library_container.library_key), + library_container.container_key, + ]) diff --git a/openedx/core/djangoapps/content/search/tasks.py b/openedx/core/djangoapps/content/search/tasks.py index 98390a12f3b3..9f8cf0198fa9 100644 --- a/openedx/core/djangoapps/content/search/tasks.py +++ b/openedx/core/djangoapps/content/search/tasks.py @@ -110,3 +110,16 @@ def update_library_components_collections(library_key_str: str, collection_key: log.info("Updating document.collections for library %s collection %s components", library_key, collection_key) api.update_library_components_collections(library_key, collection_key) + + +@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) +@set_code_owner_attribute +def update_library_container_index_doc(library_key_str: str, container_key: str) -> None: + """ + Celery task to update the content index document for a library container + """ + library_key = LibraryLocatorV2.from_string(library_key_str) + + log.info("Updating content index documents for container %s in library%s", container_key, library_key) + + api.upsert_library_container_index_doc(library_key, container_key) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 8063307d61e9..91ce6a99f9ce 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -208,6 +208,34 @@ def setUp(self): "breadcrumbs": [{"display_name": "Library"}], } + # Create a unit: + with freeze_time(created_date): + self.unit = library_api.create_container( + library_key=self.library.key, + container_type=library_api.ContainerType.Unit, + slug="unit-1", + title="Unit 1", + user_id=None, + ) + self.unit_usage_key = "lct:org1:lib:unit:unit-1" + self.unit_dict = { + "id": "lctorg1libunitunit-1-e4527f7c", + "block_id": "unit-1", + "usage_key": self.unit_usage_key, + "type": "library_container", + "display_name": "Unit 1", + # description is not set for containers + "num_children": 0, + "context_key": "lib:org1:lib", + "org": "org1", + "created": created_date.timestamp(), + "modified": created_date.timestamp(), + "access_id": lib_access.id, + "breadcrumbs": [{"display_name": "Library"}], + # "tags" should be here but we haven't implemented them yet + # "published" is not set since we haven't published it yet + } + @override_settings(MEILISEARCH_ENABLED=False) def test_reindex_meilisearch_disabled(self, mock_meilisearch): with self.assertRaises(RuntimeError): @@ -231,14 +259,16 @@ def test_reindex_meilisearch(self, mock_meilisearch): doc_problem2["collections"] = {'display_name': [], 'key': []} doc_collection = copy.deepcopy(self.collection_dict) doc_collection["tags"] = {} + doc_unit = copy.deepcopy(self.unit_dict) api.rebuild_index() - assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 3 + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 4 mock_meilisearch.return_value.index.return_value.add_documents.assert_has_calls( [ call([doc_sequential, doc_vertical]), call([doc_problem1, doc_problem2]), call([doc_collection]), + call([doc_unit]), ], any_order=True, ) @@ -259,14 +289,16 @@ def test_reindex_meilisearch_incremental(self, mock_meilisearch): doc_problem2["collections"] = {"display_name": [], "key": []} doc_collection = copy.deepcopy(self.collection_dict) doc_collection["tags"] = {} + doc_unit = copy.deepcopy(self.unit_dict) api.rebuild_index(incremental=True) - assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 3 + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 4 mock_meilisearch.return_value.index.return_value.add_documents.assert_has_calls( [ call([doc_sequential, doc_vertical]), call([doc_problem1, doc_problem2]), call([doc_collection]), + call([doc_unit]), ], any_order=True, ) @@ -280,13 +312,13 @@ def simulated_interruption(message): with pytest.raises(Exception, match="Simulated interruption"): api.rebuild_index(simulated_interruption, incremental=True) - # two more calls due to collections - assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 5 + # three more calls due to collections and containers + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 7 assert IncrementalIndexCompleted.objects.all().count() == 1 api.rebuild_index(incremental=True) assert IncrementalIndexCompleted.objects.all().count() == 0 # one missing course indexed - assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 6 + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 8 @override_settings(MEILISEARCH_ENABLED=True) def test_reset_meilisearch_index(self, mock_meilisearch): @@ -340,6 +372,22 @@ def test_reindex_meilisearch_collection_error(self, mock_meilisearch): f"Error indexing collection {self.collection}: Failed to generate document" ) + @override_settings(MEILISEARCH_ENABLED=True) + @patch( + "openedx.core.djangoapps.content.search.api.searchable_doc_for_container", + Mock(side_effect=Exception("Failed to generate document")), + ) + def test_reindex_meilisearch_container_error(self, mock_meilisearch): + + mock_logger = Mock() + api.rebuild_index(mock_logger) + assert call( + [self.unit_dict] + ) not in mock_meilisearch.return_value.index.return_value.add_documents.mock_calls + mock_logger.assert_any_call( + f"Error indexing container unit-1: Failed to generate document" + ) + @override_settings(MEILISEARCH_ENABLED=True) def test_reindex_meilisearch_library_block_error(self, mock_meilisearch): diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index b9d033777aaa..7643f9747f34 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -13,6 +13,10 @@ LibraryContainerLocator, ) +from openedx_events.content_authoring.data import LibraryContainerData +from openedx_events.content_authoring.signals import ( + LIBRARY_CONTAINER_CREATED, +) from openedx_learning.api import authoring as authoring_api from openedx_learning.api import authoring_models @@ -44,7 +48,7 @@ class ContainerMetadata(PublishableItem): @classmethod def from_container(cls, library_key, container: authoring_models.Container, associated_collections=None): """ - Construct a LibraryXBlockMetadata from a Component object. + Construct a ContainerMetadata object from a Container object. """ last_publish_log = container.versioning.last_publish_log @@ -92,13 +96,10 @@ def get_container(container_key: LibraryContainerLocator) -> ContainerMetadata: content_library = ContentLibrary.objects.get_by_key(container_key.library_key) learning_package = content_library.learning_package assert learning_package is not None - # FIXME: we need an API to get container by key, without needing to get the publishable entity ID first - container_pe = authoring_api.get_publishable_entity_by_key( - learning_package, + container = authoring_api.get_container_by_key( + learning_package.id, key=container_key.container_id, ) - container = authoring_api.get_container(container_pe.id) - # ^ Should be just authoring_api.get_container_by_key(learning_package.id, container_key.container_id) container_meta = ContainerMetadata.from_container(container_key.library_key, container) assert container_meta.container_type.value == container_key.container_type return container_meta @@ -123,7 +124,11 @@ def create_container( # Automatically generate a slug. Append a random suffix so it should be unique. slug = slugify(title, allow_unicode=True) + '-' + uuid4().hex[-6:] # Make sure the slug is valid by first creating a key for the new container: - LibraryContainerLocator(library_key=library_key, container_type=container_type.value, container_id=slug) + container_key = LibraryContainerLocator( + library_key=library_key, + container_type=container_type.value, + container_id=slug, + ) # Then try creating the actual container: match container_type: case ContainerType.Unit: @@ -136,6 +141,14 @@ def create_container( ) case _: raise ValueError(f"Invalid container type: {container_type}") + + LIBRARY_CONTAINER_CREATED.send_event( + library_container=LibraryContainerData( + library_key=library_key, + container_key=str(container_key), + ) + ) + return ContainerMetadata.from_container(library_key, container) @@ -150,12 +163,10 @@ def get_container_children( content_library = ContentLibrary.objects.get_by_key(container_key.library_key) learning_package = content_library.learning_package assert learning_package is not None - # FIXME: we need an API to get container by key, without needing to get the publishable entity ID first - container_pe = authoring_api.get_publishable_entity_by_key( - learning_package, + container = authoring_api.get_container_by_key( + learning_package.id, key=container_key.container_id, ) - assert container_pe.container is not None - child_entities = authoring_api.get_entities_in_container(container_pe.container, published=published) + child_entities = authoring_api.get_entities_in_container(container, published=published) # TODO: convert the return type to list[ContainerMetadata | LibraryXBlockMetadata] ? return child_entities diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index dfad2ab0616b..39479daffb3a 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -5,6 +5,12 @@ import ddt from freezegun import freeze_time +from unittest import mock + +from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_events.content_authoring.data import LibraryContainerData +from openedx_events.content_authoring.signals import LIBRARY_CONTAINER_CREATED +from openedx_events.tests.utils import OpenEdxEventsTestMixin from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest from openedx.core.djangolib.testing.utils import skip_unless_cms @@ -12,7 +18,7 @@ @skip_unless_cms @ddt.ddt -class ContainersTestCase(ContentLibrariesRestApiTest): +class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): """ Tests for containers (Sections, Subsections, Units) in Content Libraries. @@ -30,14 +36,19 @@ class ContainersTestCase(ContentLibrariesRestApiTest): new fields to an API response, which are backwards compatible, won't break any tests, but backwards-incompatible API changes will. """ - # Note: if we need events at some point, add OpenEdxEventsTestMixin and set the list here; see other test suites. - # ENABLED_OPENEDX_EVENTS = [] + ENABLED_OPENEDX_EVENTS = [ + LIBRARY_CONTAINER_CREATED.event_type, + ] def test_unit_crud(self): """ Test Create, Read, Update, and Delete of a Unit """ lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") + lib_key = LibraryLocatorV2.from_string(lib["id"]) + + create_receiver = mock.Mock() + LIBRARY_CONTAINER_CREATED.connect(create_receiver) # Create a unit: create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc) @@ -58,6 +69,18 @@ def test_unit_crud(self): } self.assertDictContainsEntries(container_data, expected_data) + assert create_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_CREATED, + "sender": None, + "library_container": LibraryContainerData( + lib_key, + container_key="lct:CL-TEST:containers:unit:u1", + ), + }, + create_receiver.call_args_list[0].kwargs, + ) # Fetch the unit: unit_as_read = self._get_container(container_data["container_key"]) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index e5d5a2714c83..f65833867f07 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -131,7 +131,7 @@ optimizely-sdk<5.0 # Date: 2023-09-18 # pinning this version to avoid updates while the library is being developed # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-learning==0.19.0 +openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index f7bf1581675e..edc0307da27c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -816,7 +816,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events==9.18.2 +openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers # via # -r requirements/edx/kernel.in # edx-enterprise @@ -832,7 +832,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.8 # via -r requirements/edx/kernel.in -openedx-learning==0.19.0 +openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 8bc47968c52e..54f89fbe194a 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1377,7 +1377,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==9.18.2 +openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1397,7 +1397,7 @@ openedx-forum==0.1.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-learning==0.19.0 +openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 59f1a9a1c8df..e2511acdce71 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -988,7 +988,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.18.2 +openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers # via # -r requirements/edx/base.txt # edx-enterprise @@ -1004,7 +1004,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.8 # via -r requirements/edx/base.txt -openedx-learning==0.19.0 +openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 2d4af74d17bb..e0c561d3d347 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1046,7 +1046,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.18.2 +openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers # via # -r requirements/edx/base.txt # edx-enterprise @@ -1062,7 +1062,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.8 # via -r requirements/edx/base.txt -openedx-learning==0.19.0 +openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 52f28223d5f28d7980bf831415da0bd73f82794b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 17 Mar 2025 08:50:41 -0300 Subject: [PATCH 14/25] fix: pylint --- openedx/core/djangoapps/content/search/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 91ce6a99f9ce..22e4a68d0392 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -385,7 +385,7 @@ def test_reindex_meilisearch_container_error(self, mock_meilisearch): [self.unit_dict] ) not in mock_meilisearch.return_value.index.return_value.add_documents.mock_calls mock_logger.assert_any_call( - f"Error indexing container unit-1: Failed to generate document" + "Error indexing container unit-1: Failed to generate document" ) @override_settings(MEILISEARCH_ENABLED=True) From 182c608ba873ba2ae15948dc78404b405de50678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 17 Mar 2025 09:01:23 -0300 Subject: [PATCH 15/25] fix: temp requirement --- requirements/edx/kernel.in | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 4b8df657decd..6b1860154d69 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -115,7 +115,8 @@ olxcleaner openedx-atlas # CLI tool to manage translations openedx-calc # Library supporting mathematical calculations for Open edX openedx-django-require -openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) +# openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) +openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) openedx-forum # Open edX forum v2 application openedx-learning # Open edX Learning core (experimental) From 9459aad8d666edeeb004a860b0b1f318eb16d5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 17 Mar 2025 11:32:44 -0300 Subject: [PATCH 16/25] fix: search index container events/tasks --- openedx/core/djangoapps/content/search/api.py | 10 +++------- openedx/core/djangoapps/content/search/documents.py | 1 + openedx/core/djangoapps/content/search/tasks.py | 7 ++++--- .../core/djangoapps/content/search/tests/test_api.py | 1 + .../djangoapps/content/search/tests/test_documents.py | 1 + 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index e2f8379ddc84..ad4736e66088 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -18,7 +18,7 @@ from meilisearch.errors import MeilisearchApiError, MeilisearchError from meilisearch.models.task import TaskInfo from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryCollectionLocator from openedx_learning.api import authoring as authoring_api from common.djangoapps.student.roles import GlobalStaff from rest_framework.request import Request @@ -774,17 +774,13 @@ def update_library_components_collections( _update_index_docs(docs) -def upsert_library_container_index_doc(library_key: LibraryLocatorV2, container_key: str) -> None: +def upsert_library_container_index_doc(container_key: LibraryContainerLocator) -> None: """ Creates, updates, or deletes the document for the given Library Container in the search index. TODO: add support for indexing a container's components, like upsert_library_collection_index_doc does. """ - container_metadata = lib_api.ContainerMetadata.from_container( - library_key, - container_key, - ) - doc = searchable_doc_for_container(container_metadata.container_key) + doc = searchable_doc_for_container(container_key) # Soft-deleted/disabled containers are removed from the index # and their components updated. diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 6e650af263f1..d795db8035c8 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -567,6 +567,7 @@ def searchable_doc_for_container( Fields.org: str(container_key.library_key.org), # In the future, this may be either course_container or library_container Fields.type: DocType.library_container, + Fields.block_type: container_key.container_type, Fields.usage_key: str(container_key), # Field name isn't exact but this is the closest match Fields.block_id: container_key.container_id, # Field name isn't exact but this is the closest match Fields.access_id: _meili_access_id_from_context_key(container_key.library_key), diff --git a/openedx/core/djangoapps/content/search/tasks.py b/openedx/core/djangoapps/content/search/tasks.py index 9f8cf0198fa9..f23ca9aa304e 100644 --- a/openedx/core/djangoapps/content/search/tasks.py +++ b/openedx/core/djangoapps/content/search/tasks.py @@ -11,7 +11,7 @@ from edx_django_utils.monitoring import set_code_owner_attribute from meilisearch.errors import MeilisearchError from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from . import api @@ -114,12 +114,13 @@ def update_library_components_collections(library_key_str: str, collection_key: @shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) @set_code_owner_attribute -def update_library_container_index_doc(library_key_str: str, container_key: str) -> None: +def update_library_container_index_doc(library_key_str: str, container_key_str: str) -> None: """ Celery task to update the content index document for a library container """ library_key = LibraryLocatorV2.from_string(library_key_str) + container_key = LibraryContainerLocator.from_string(container_key_str) log.info("Updating content index documents for container %s in library%s", container_key, library_key) - api.upsert_library_container_index_doc(library_key, container_key) + api.upsert_library_container_index_doc(container_key) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 22e4a68d0392..7fb3fe3e2c22 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -221,6 +221,7 @@ def setUp(self): self.unit_dict = { "id": "lctorg1libunitunit-1-e4527f7c", "block_id": "unit-1", + "block_type": "unit", "usage_key": self.unit_usage_key, "type": "library_container", "display_name": "Unit 1", diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 20b7ec88585b..38b1d607ab05 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -515,6 +515,7 @@ def test_draft_container(self): assert doc == { "id": "lctedx2012_fallunitunit1-edd13a0c", "block_id": "unit1", + "block_type": "unit", "usage_key": "lct:edX:2012_Fall:unit:unit1", "type": "library_container", "org": "edX", From 726ae8224494b6a7fb01f15aa1e44e135f01644e Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Wed, 19 Mar 2025 09:22:42 +1030 Subject: [PATCH 17/25] feat: add get_library_container_usage_key to libraries API and use it when search indexing containers --- openedx/core/djangoapps/content/search/api.py | 6 ++-- .../content_libraries/api/containers.py | 34 ++++++++++++++----- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index ad4736e66088..6aab6f76022a 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -481,13 +481,13 @@ def index_container_batch(batch, num_done, library_key) -> int: docs = [] for container in batch: try: - container_metadata = lib_api.ContainerMetadata.from_container( + container_key = lib_api.library_container_usage_key( library_key, container, ) - doc = searchable_doc_for_container(container_metadata.container_key) + doc = searchable_doc_for_container(container_key) # TODO: when we add container tags - # doc.update(searchable_doc_tags_for_container(library_key, container.key)) + # doc.update(searchable_doc_tags_for_container(container_key)) docs.append(doc) except Exception as err: # pylint: disable=broad-except status_cb(f"Error indexing container {container.key}: {err}") diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 7643f9747f34..b1e934bd7584 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -30,6 +30,7 @@ "get_container", "create_container", "get_container_children", + "library_container_usage_key", ] @@ -51,9 +52,11 @@ def from_container(cls, library_key, container: authoring_models.Container, asso Construct a ContainerMetadata object from a Container object. """ last_publish_log = container.versioning.last_publish_log - - assert container.unit is not None - container_type = ContainerType.Unit + container_key = library_container_usage_key( + library_key, + container=container, + ) + container_type = ContainerType(container_key.container_type) published_by = "" if last_publish_log and last_publish_log.published_by: @@ -68,11 +71,7 @@ def from_container(cls, library_key, container: authoring_models.Container, asso last_draft_created_by = "" return cls( - container_key=LibraryContainerLocator( - library_key, - container_type=container_type.value, - container_id=container.publishable_entity.key, - ), + container_key=container_key, container_type=container_type, display_name=draft.title, created=container.created, @@ -88,6 +87,25 @@ def from_container(cls, library_key, container: authoring_models.Container, asso ) +def library_container_usage_key( + library_key: LibraryLocatorV2, + container: authoring_models.Container, +) -> LibraryContainerLocator: + """ + Returns a LibraryContainerLocator for the given library + container. + + Currently only supports Unit-type containers; will support other container types in future. + """ + assert container.unit is not None + container_type = ContainerType.Unit + + return LibraryContainerLocator( + library_key, + container_type=container_type.value, + container_id=container.publishable_entity.key, + ) + + def get_container(container_key: LibraryContainerLocator) -> ContainerMetadata: """ Get a container (a Section, Subsection, or Unit). From 9b75fce96a1be4c3416c2d202a350d55ab5a56fc Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Wed, 19 Mar 2025 09:23:19 +1030 Subject: [PATCH 18/25] fix: index all containers during reindex_studio --- openedx/core/djangoapps/content/search/api.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 6aab6f76022a..3fce7e2d338e 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -523,19 +523,19 @@ def index_container_batch(batch, num_done, library_key) -> int: IncrementalIndexCompleted.objects.get_or_create(context_key=lib_key) status_cb(f"{num_collections_done}/{num_collections} collections indexed for library {lib_key}") - # Similarly, batch process Units in pages of 100 - units = authoring_api.get_units(library.learning_package_id) - num_units = units.count() - num_units_done = 0 - status_cb(f"{num_units_done}/{num_units}. Now indexing units in library {lib_key}") - paginator = Paginator(units, 100) + # Similarly, batch process Containers (units, sections, etc) in pages of 100 + containers = authoring_api.get_containers(library.learning_package_id) + num_containers = containers.count() + num_containers_done = 0 + status_cb(f"{num_containers_done}/{num_containers}. Now indexing containers in library {lib_key}") + paginator = Paginator(containers, 100) for p in paginator.page_range: - num_units_done = index_container_batch( + num_containers_done = index_container_batch( paginator.page(p).object_list, - num_units_done, + num_containers_done, lib_key, ) - status_cb(f"{num_units_done}/{num_units} units indexed for library {lib_key}") + status_cb(f"{num_containers_done}/{num_containers} containers indexed for library {lib_key}") if incremental: IncrementalIndexCompleted.objects.get_or_create(context_key=lib_key) From faa27e24c11f03361cf1f6bb4e0d85a47a2754ef Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Wed, 19 Mar 2025 09:26:40 +1030 Subject: [PATCH 19/25] chore: bump openedx-events requirement --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/kernel.in | 3 +-- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 1cfd52ad25d3..48261de17ce9 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -810,7 +810,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers +openedx-events==9.20.0 # via # -r requirements/edx/kernel.in # edx-enterprise diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index e8ca7480421a..9af8ee761847 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1370,7 +1370,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers +openedx-events==9.20.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index ce654570d40f..55333633fdd3 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -982,7 +982,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers +openedx-events==9.20.0 # via # -r requirements/edx/base.txt # edx-enterprise diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 6b1860154d69..4b8df657decd 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -115,8 +115,7 @@ olxcleaner openedx-atlas # CLI tool to manage translations openedx-calc # Library supporting mathematical calculations for Open edX openedx-django-require -# openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) -openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers +openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) openedx-forum # Open edX forum v2 application openedx-learning # Open edX Learning core (experimental) diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 7bc131b275a8..e8793d11e47a 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1040,7 +1040,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events @ git+https://github.com/open-craft/openedx-events.git@jill/library-containers +openedx-events==9.20.0 # via # -r requirements/edx/base.txt # edx-enterprise From 1dbbabe786b0d92492d1e4df3d895d6c8d8f0940 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Wed, 19 Mar 2025 09:47:18 +1030 Subject: [PATCH 20/25] fix: address review comments --- openedx/core/djangoapps/content/search/documents.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index d795db8035c8..14c7f712dcfd 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -564,9 +564,10 @@ def searchable_doc_for_container( doc = { Fields.id: meili_id_from_opaque_key(container_key), Fields.context_key: str(container_key.library_key), - Fields.org: str(container_key.library_key.org), + Fields.org: str(container_key.org), # In the future, this may be either course_container or library_container Fields.type: DocType.library_container, + # To check if it is "unit", "section", "subsection", etc.. Fields.block_type: container_key.container_type, Fields.usage_key: str(container_key), # Field name isn't exact but this is the closest match Fields.block_id: container_key.container_id, # Field name isn't exact but this is the closest match From 556fb7815d40f18d47e25092e89203865d2a9856 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 20 Mar 2025 06:27:10 +1030 Subject: [PATCH 21/25] chore: bumps openedx-learning to 0.19.1 --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 834c83a186be..8c9a3ee9d006 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -131,7 +131,7 @@ optimizely-sdk<5.0 # Date: 2023-09-18 # pinning this version to avoid updates while the library is being developed # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api +openedx-learning==0.19.1 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 48261de17ce9..78f919d9b6b2 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -826,7 +826,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.8 # via -r requirements/edx/kernel.in -openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api +openedx-learning==0.19.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 9af8ee761847..e90fcfa3b517 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1390,7 +1390,7 @@ openedx-forum==0.1.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api +openedx-learning==0.19.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 55333633fdd3..37edd2992c5f 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -998,7 +998,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.8 # via -r requirements/edx/base.txt -openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api +openedx-learning==0.19.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index e8793d11e47a..df57e97c6605 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1056,7 +1056,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.8 # via -r requirements/edx/base.txt -openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/containers-api +openedx-learning==0.19.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 70814889239ed340de95d406453f76a885e1c50b Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Fri, 21 Mar 2025 14:55:06 +1030 Subject: [PATCH 22/25] fix: rename api method to library_container_locator since container keys are locators, not usage keys --- openedx/core/djangoapps/content/search/api.py | 2 +- openedx/core/djangoapps/content_libraries/api/containers.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 3fce7e2d338e..a597ae51e865 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -481,7 +481,7 @@ def index_container_batch(batch, num_done, library_key) -> int: docs = [] for container in batch: try: - container_key = lib_api.library_container_usage_key( + container_key = lib_api.library_container_locator( library_key, container, ) diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index b1e934bd7584..b562913baadc 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -30,7 +30,7 @@ "get_container", "create_container", "get_container_children", - "library_container_usage_key", + "library_container_locator", ] @@ -52,7 +52,7 @@ def from_container(cls, library_key, container: authoring_models.Container, asso Construct a ContainerMetadata object from a Container object. """ last_publish_log = container.versioning.last_publish_log - container_key = library_container_usage_key( + container_key = library_container_locator( library_key, container=container, ) @@ -87,7 +87,7 @@ def from_container(cls, library_key, container: authoring_models.Container, asso ) -def library_container_usage_key( +def library_container_locator( library_key: LibraryLocatorV2, container: authoring_models.Container, ) -> LibraryContainerLocator: From 42bccba1b91e12ab2906bf2d1e2fb7f0b85bdb4f Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Mon, 24 Mar 2025 14:06:54 +1030 Subject: [PATCH 23/25] chore: bumps opaque-keys dependency --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/kernel.in | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index bf0986fc30fb..4c9dc151f413 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -479,7 +479,7 @@ edx-milestones==0.6.0 # via -r requirements/edx/kernel.in edx-name-affirmation==3.0.1 # via -r requirements/edx/kernel.in -edx-opaque-keys[django] @ git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys +edx-opaque-keys[django]==2.12.0 # via # -r requirements/edx/kernel.in # edx-bulk-grades diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 5bc27db5df5d..51adddbed93a 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -768,7 +768,7 @@ edx-name-affirmation==3.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-opaque-keys[django] @ git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys +edx-opaque-keys[django]==2.12.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 939a4fb62cbf..07246bcc10cd 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -563,7 +563,7 @@ edx-milestones==0.6.0 # via -r requirements/edx/base.txt edx-name-affirmation==3.0.1 # via -r requirements/edx/base.txt -edx-opaque-keys[django] @ git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys +edx-opaque-keys[django]==2.12.0 # via # -r requirements/edx/base.txt # edx-bulk-grades diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 4b8df657decd..55fa2f29082d 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -75,7 +75,7 @@ edx-event-bus-kafka>=5.6.0 # Kafka implementation of event bus edx-event-bus-redis edx-milestones edx-name-affirmation -git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys#egg=edx-opaque-keys +edx-opaque-keys>=2.12.0 edx-organizations edx-proctoring>=2.0.1 # using hash to support django42 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index e3d30663475d..d3d4c878338c 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -590,7 +590,7 @@ edx-milestones==0.6.0 # via -r requirements/edx/base.txt edx-name-affirmation==3.0.1 # via -r requirements/edx/base.txt -edx-opaque-keys[django] @ git+https://github.com/open-craft/opaque-keys.git@chris/FAL-4108-create-container-keys +edx-opaque-keys[django]==2.12.0 # via # -r requirements/edx/base.txt # edx-bulk-grades From e0f4380f22c7df325607b0f93f2dcc193bdd0278 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 27 Mar 2025 10:48:55 +1030 Subject: [PATCH 24/25] test: fix misnamed unit_usage_key --- openedx/core/djangoapps/content/search/tests/test_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 7fb3fe3e2c22..2decf2374fac 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -217,12 +217,12 @@ def setUp(self): title="Unit 1", user_id=None, ) - self.unit_usage_key = "lct:org1:lib:unit:unit-1" + self.unit_key = "lct:org1:lib:unit:unit-1" self.unit_dict = { "id": "lctorg1libunitunit-1-e4527f7c", "block_id": "unit-1", "block_type": "unit", - "usage_key": self.unit_usage_key, + "usage_key": self.unit_key, "type": "library_container", "display_name": "Unit 1", # description is not set for containers From 1ef3b1e024c26b70a38e52725c4861118c2b5d20 Mon Sep 17 00:00:00 2001 From: Jillian Date: Fri, 28 Mar 2025 10:16:37 +1030 Subject: [PATCH 25/25] feat: adds APIs to update or delete a container (#757) * feat: adds python and REST APIs to update a container's display_name * refactor: adds _get_container method to api to reduce code duplication * feat: adds python and REST APIs to delete a container * test: add container permission tests --- .../content_libraries/api/containers.py | 85 +++++++++++++++++-- .../content_libraries/api/libraries.py | 4 - .../content_libraries/rest_api/containers.py | 45 +++++++++- .../content_libraries/rest_api/serializers.py | 7 ++ .../content_libraries/rest_api/utils.py | 3 + .../content_libraries/tests/base.py | 9 ++ .../tests/test_containers.py | 79 ++++++++++++++++- .../core/djangoapps/content_libraries/urls.py | 2 +- 8 files changed, 221 insertions(+), 13 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index b562913baadc..5b24b6540b10 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -16,24 +16,33 @@ from openedx_events.content_authoring.data import LibraryContainerData from openedx_events.content_authoring.signals import ( LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, ) from openedx_learning.api import authoring as authoring_api -from openedx_learning.api import authoring_models +from openedx_learning.api.authoring_models import Container from ..models import ContentLibrary from .libraries import PublishableItem + # The public API is only the following symbols: __all__ = [ + "ContentLibraryContainerNotFound", "ContainerMetadata", "ContainerType", "get_container", "create_container", "get_container_children", "library_container_locator", + "update_container", + "delete_container", ] +ContentLibraryContainerNotFound = Container.DoesNotExist + + class ContainerType(Enum): Unit = "unit" @@ -47,7 +56,7 @@ class ContainerMetadata(PublishableItem): container_type: ContainerType @classmethod - def from_container(cls, library_key, container: authoring_models.Container, associated_collections=None): + def from_container(cls, library_key, container: Container, associated_collections=None): """ Construct a ContainerMetadata object from a Container object. """ @@ -89,7 +98,7 @@ def from_container(cls, library_key, container: authoring_models.Container, asso def library_container_locator( library_key: LibraryLocatorV2, - container: authoring_models.Container, + container: Container, ) -> LibraryContainerLocator: """ Returns a LibraryContainerLocator for the given library + container. @@ -106,9 +115,11 @@ def library_container_locator( ) -def get_container(container_key: LibraryContainerLocator) -> ContainerMetadata: +def _get_container(container_key: LibraryContainerLocator) -> Container: """ - Get a container (a Section, Subsection, or Unit). + 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.library_key) @@ -118,6 +129,16 @@ def get_container(container_key: LibraryContainerLocator) -> ContainerMetadata: learning_package.id, key=container_key.container_id, ) + if container and container.versioning.draft: + return container + raise ContentLibraryContainerNotFound + + +def get_container(container_key: LibraryContainerLocator) -> ContainerMetadata: + """ + Get a container (a Section, Subsection, or Unit). + """ + container = _get_container(container_key) container_meta = ContainerMetadata.from_container(container_key.library_key, container) assert container_meta.container_type.value == container_key.container_type return container_meta @@ -170,6 +191,60 @@ def create_container( return ContainerMetadata.from_container(library_key, container) +def update_container( + container_key: LibraryContainerLocator, + display_name: str, + user_id: int | None, +) -> ContainerMetadata: + """ + Update a container (e.g. a Unit) title. + """ + container = _get_container(container_key) + library_key = container_key.library_key + + assert container.unit + unit_version = authoring_api.create_next_unit_version( + container.unit, + title=display_name, + created=datetime.now(), + created_by=user_id, + ) + + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + library_key=library_key, + container_key=str(container_key), + ) + ) + + return ContainerMetadata.from_container(library_key, unit_version.container) + + +def delete_container( + container_key: LibraryContainerLocator, +) -> None: + """ + Delete a container (e.g. a Unit) (soft delete). + + No-op if container doesn't exist or has already been soft-deleted. + """ + try: + container = _get_container(container_key) + except ContentLibraryContainerNotFound: + return + + authoring_api.soft_delete_draft(container.pk) + + LIBRARY_CONTAINER_DELETED.send_event( + library_container=LibraryContainerData( + library_key=container_key.library_key, + container_key=str(container_key), + ) + ) + + # TODO: trigger a LIBRARY_COLLECTION_UPDATED for each collection the container was in + + def get_container_children( container_key: LibraryContainerLocator, published=False, diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index fd89ceb0d3fc..13d41921e860 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -97,7 +97,6 @@ from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import ( Collection, - Container, Component, ComponentVersion, MediaType, @@ -128,7 +127,6 @@ # Exceptions - maybe move them to a new file? "ContentLibraryNotFound", "ContentLibraryCollectionNotFound", - "ContentLibraryContainerNotFound", "ContentLibraryBlockNotFound", "LibraryAlreadyExists", "LibraryCollectionAlreadyExists", @@ -181,8 +179,6 @@ ContentLibraryCollectionNotFound = Collection.DoesNotExist -ContentLibraryContainerNotFound = Container.DoesNotExist - class ContentLibraryBlockNotFound(XBlockNotFoundError): """ XBlock not found in the content library """ diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index fd96315391cd..ad23a51d55b3 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -14,6 +14,7 @@ from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator from rest_framework.generics import GenericAPIView from rest_framework.response import Response +from rest_framework.status import HTTP_204_NO_CONTENT from openedx.core.djangoapps.content_libraries import api, permissions from openedx.core.lib.api.view_utils import view_auth_classes @@ -62,7 +63,7 @@ def post(self, request, lib_key_str): @view_auth_classes() class LibraryContainerView(GenericAPIView): """ - View to get data about a specific container (a section, subsection, or unit) + View to retrieve or update data about a specific container (a section, subsection, or unit) """ serializer_class = serializers.LibraryContainerMetadataSerializer @@ -81,3 +82,45 @@ def get(self, request, container_key: LibraryContainerLocator): ) container = api.get_container(container_key) return Response(serializers.LibraryContainerMetadataSerializer(container).data) + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.LibraryContainerUpdateSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def patch(self, request, container_key: LibraryContainerLocator): + """ + Update a Container. + """ + api.require_permission_for_library_key( + container_key.library_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + serializer = serializers.LibraryContainerUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + container = api.update_container( + container_key, + display_name=serializer.validated_data['display_name'], + user_id=request.user.id, + ) + + return Response(serializers.LibraryContainerMetadataSerializer(container).data) + + @convert_exceptions + def delete(self, request, container_key: LibraryContainerLocator): + """ + Delete a Container (soft delete). + """ + api.require_permission_for_library_key( + container_key.library_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + + api.delete_container( + container_key, + ) + + return Response({}, status=HTTP_204_NO_CONTENT) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index cac8fddd79ad..67b8ec00e56f 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -270,6 +270,13 @@ def to_internal_value(self, data): return result +class LibraryContainerUpdateSerializer(serializers.Serializer): + """ + Serializer for updating metadata for Containers like Sections, Subsections, Units + """ + display_name = serializers.CharField() + + class ContentLibraryBlockImportTaskSerializer(serializers.ModelSerializer): """ Serializer for a Content Library block import task. diff --git a/openedx/core/djangoapps/content_libraries/rest_api/utils.py b/openedx/core/djangoapps/content_libraries/rest_api/utils.py index 53b5dd0b4059..99825fbe75f8 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/utils.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/utils.py @@ -34,6 +34,9 @@ def wrapped_fn(*args, **kwargs): except api.ContentLibraryCollectionNotFound: log.exception("Collection not found in content library") raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryContainerNotFound: + log.exception("Container not found in content library") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from except api.LibraryCollectionAlreadyExists as exc: log.exception(str(exc)) raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index e4b7b92e088f..d8030afae7f0 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -363,3 +363,12 @@ def _create_container(self, lib_key, container_type, slug: str | None, display_n def _get_container(self, container_key: str, expect_response=200): """ Get a container (unit etc.) """ return self._api('get', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) + + def _update_container(self, container_key: str, display_name: str, expect_response=200): + """ Update a container (unit etc.) """ + data = {"display_name": display_name} + return self._api('patch', URL_LIB_CONTAINER.format(container_key=container_key), data, expect_response) + + def _delete_container(self, container_key: str, expect_response=204): + """ Delete a container (unit etc.) """ + return self._api('delete', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 39479daffb3a..23a519899a85 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -9,9 +9,14 @@ from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_events.content_authoring.data import LibraryContainerData -from openedx_events.content_authoring.signals import LIBRARY_CONTAINER_CREATED +from openedx_events.content_authoring.signals import ( + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, +) from openedx_events.tests.utils import OpenEdxEventsTestMixin +from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest from openedx.core.djangolib.testing.utils import skip_unless_cms @@ -38,6 +43,8 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): """ ENABLED_OPENEDX_EVENTS = [ LIBRARY_CONTAINER_CREATED.event_type, + LIBRARY_CONTAINER_DELETED.event_type, + LIBRARY_CONTAINER_UPDATED.event_type, ] def test_unit_crud(self): @@ -50,6 +57,12 @@ def test_unit_crud(self): create_receiver = mock.Mock() LIBRARY_CONTAINER_CREATED.connect(create_receiver) + update_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(update_receiver) + + delete_receiver = mock.Mock() + LIBRARY_CONTAINER_DELETED.connect(delete_receiver) + # Create a unit: create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc) with freeze_time(create_date): @@ -87,7 +100,69 @@ def test_unit_crud(self): # make sure it contains the same data when we read it back: self.assertDictContainsEntries(unit_as_read, expected_data) - # TODO: test that a regular user with read-only permissions on the library cannot create units + # Update the unit: + modified_date = datetime(2024, 10, 9, 8, 7, 6, tzinfo=timezone.utc) + with freeze_time(modified_date): + container_data = self._update_container("lct:CL-TEST:containers:unit:u1", display_name="Unit ABC") + expected_data['last_draft_created'] = expected_data['modified'] = '2024-10-09T08:07:06Z' + expected_data['display_name'] = 'Unit ABC' + self.assertDictContainsEntries(container_data, expected_data) + + assert update_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + lib_key, + container_key="lct:CL-TEST:containers:unit:u1", + ), + }, + update_receiver.call_args_list[0].kwargs, + ) + + # Re-fetch the unit + unit_as_re_read = self._get_container(container_data["container_key"]) + # make sure it contains the same data when we read it back: + self.assertDictContainsEntries(unit_as_re_read, expected_data) + + # Delete the unit + self._delete_container(container_data["container_key"]) + self._get_container(container_data["container_key"], expect_response=404) + assert delete_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_DELETED, + "sender": None, + "library_container": LibraryContainerData( + lib_key, + container_key="lct:CL-TEST:containers:unit:u1", + ), + }, + delete_receiver.call_args_list[0].kwargs, + ) + + def test_unit_permissions(self): + """ + Test that a regular user with read-only permissions on the library cannot create, update, or delete units. + """ + lib = self._create_library(slug="containers2", title="Container Test Library 2", description="Unit permissions") + container_data = self._create_container(lib["id"], "unit", slug="u2", display_name="Test Unit") + + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + self._create_container(lib["id"], "unit", slug="u3", display_name="Test Unit", expect_response=403) + self._get_container(container_data["container_key"], expect_response=403) + self._update_container(container_data["container_key"], display_name="Unit ABC", expect_response=403) + self._delete_container(container_data["container_key"], expect_response=403) + + # Granting read-only permissions on the library should only allow retrieval, nothing else. + self._add_user_by_email(lib["id"], random_user.email, access_level="read") + with self.as_user(random_user): + self._create_container(lib["id"], "unit", slug="u2", display_name="Test Unit", expect_response=403) + self._get_container(container_data["container_key"], expect_response=200) + self._update_container(container_data["container_key"], display_name="Unit ABC", expect_response=403) + self._delete_container(container_data["container_key"], expect_response=403) def test_unit_gets_auto_slugs(self): """ diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index c3e7b8130057..1656a8589616 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -78,7 +78,7 @@ ])), # Containers are Sections, Subsections, and Units path('containers//', include([ - # Get metadata about a specific container in this library, or delete the container: + # Get metadata about a specific container in this library, update or delete the container: path('', containers.LibraryContainerView.as_view()), # Update collections for a given container # path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'),