From 6049f9964abe117daea6df1fdc37a9ae61109e4f Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Thu, 23 Apr 2026 15:20:22 -0400 Subject: [PATCH 01/17] fix: Allow `--reset ` or `--init` on `reindex_studio` as no-ops --- .../content/search/management/commands/reindex_studio.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py b/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py index b9b79192a3c4..a1d7318f16c0 100644 --- a/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py +++ b/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py @@ -73,14 +73,14 @@ def handle(self, *args, **options): raise CommandError("Meilisearch is not enabled. Please set MEILISEARCH_ENABLED to True in your settings.") if options["reset"]: - raise CommandError( + log.warning( "The --reset flag has been removed. " "Index reset is now handled automatically by post_migrate reconciliation. " "Run: ./manage.py cms migrate" ) if options["init"]: - raise CommandError( + log.warning( "The --init flag has been removed. " "Index initialization is now handled automatically by post_migrate reconciliation. " "Run: ./manage.py cms migrate" From 009c3c64618ef8e273786d48c1e23283dd8dbe92 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Tue, 21 Apr 2026 21:48:25 -0400 Subject: [PATCH 02/17] temp: fail fast false --- .github/workflows/unit-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 2d94a818ef6f..a057b64752f7 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,6 +20,7 @@ jobs: name: ${{ matrix.shard_name }}(py=${{ matrix.python-version }},dj=${{ matrix.django-version }},mongo=${{ matrix.mongo-version }}) runs-on: ${{ matrix.os-version }} strategy: + fail-fast: false matrix: python-version: - "3.12" From 73a7936abe5437d260963ad8e2fc0a2eea27659b Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Tue, 21 Apr 2026 13:12:42 -0400 Subject: [PATCH 03/17] build: Upgrade openedx-core pin, 0.39.2 -> 0.44.0 . --- 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 f051cc8f6114..73e9f6effa10 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -65,7 +65,7 @@ numpy<2.0.0 # breaking changes which openedx-core devs want to roll out manually. New patch versions # are OK to accept automatically. # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-core<0.40 +openedx-core<0.45 # 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 298f05e12aab..71790d659ff7 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -840,7 +840,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/kernel.in # xblocks-contrib -openedx-core==0.39.2 +openedx-core==0.44.0 # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index ea0fd8e68eae..3ad4c94197fd 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1394,7 +1394,7 @@ openedx-calc==5.0.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # xblocks-contrib -openedx-core==0.39.2 +openedx-core==0.44.0 # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 194b5438ce1f..b7f29a68f1df 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1018,7 +1018,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core==0.39.2 +openedx-core==0.44.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index eb9732129f73..1e14484defc2 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1065,7 +1065,7 @@ openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core==0.39.2 +openedx-core==0.44.0 # via # -c requirements/constraints.txt # -r requirements/edx/base.txt From 57ef381f2db59af7b1f4a135eff0104fe639e151 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Tue, 21 Apr 2026 13:49:39 -0400 Subject: [PATCH 04/17] refactor: Rename key_field to ref_field for openedx-core 0.43.0 Renames the openedx_django_lib.fields import in EntityLinkBase from the removed key_field helper to ref_field. Co-Authored-By: Claude Opus 4.7 (1M context) --- cms/djangoapps/contentstore/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index c39e04c299dc..d80517c2a842 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -17,7 +17,7 @@ from opaque_keys.edx.locator import LibraryContainerLocator from openedx_content.api import get_published_version from openedx_content.models_api import Component, Container -from openedx_django_lib.fields import immutable_uuid_field, key_field, manual_date_time_field +from openedx_django_lib.fields import immutable_uuid_field, manual_date_time_field, ref_field logger = logging.getLogger(__name__) @@ -87,7 +87,7 @@ class EntityLinkBase(models.Model): """ uuid = immutable_uuid_field() # Search by library/upstream context key - upstream_context_key = key_field( + upstream_context_key = ref_field( help_text=_("Upstream context key i.e., learning_package/library key"), db_index=True, ) From cabfaf0363ca52a1132d043c19a705dc094883a1 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Tue, 21 Apr 2026 14:02:22 -0400 Subject: [PATCH 05/17] refactor: Rename LearningPackage.key to package_ref for openedx-core 0.43.0 Updates callers of get_learning_package_by_key (renamed to get_learning_package_by_ref), create_learning_package, and update_learning_package to use the new package_ref kwarg, and switches .key attribute reads to .package_ref. Co-Authored-By: Claude Opus 4.7 (1M context) --- cms/djangoapps/modulestore_migrator/api/read_api.py | 2 +- openedx/core/djangoapps/content/search/tests/test_api.py | 2 +- openedx/core/djangoapps/content_libraries/api/libraries.py | 4 ++-- openedx/core/djangoapps/content_libraries/rest_api/blocks.py | 2 +- openedx/core/djangoapps/xblock/api.py | 2 +- .../core/djangoapps/xblock/runtime/openedx_content_runtime.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cms/djangoapps/modulestore_migrator/api/read_api.py b/cms/djangoapps/modulestore_migrator/api/read_api.py index 064223dc9633..e62a684476f6 100644 --- a/cms/djangoapps/modulestore_migrator/api/read_api.py +++ b/cms/djangoapps/modulestore_migrator/api/read_api.py @@ -209,7 +209,7 @@ def _block_migration_success( """ Build an instance of the migration success dataclass """ - target_library_key = LibraryLocatorV2.from_string(target.learning_package.key) + target_library_key = LibraryLocatorV2.from_string(target.learning_package.package_ref) target_key: LibraryUsageLocatorV2 | LibraryContainerLocator if hasattr(target, "component"): target_key = library_component_usage_key(target_library_key, target.component) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 8a29bb450326..3a4cdc32eafa 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -188,7 +188,7 @@ def setUp(self) -> None: tagging_api.add_tag_to_taxonomy(self.taxonomyB, "four") # Create a collection: - self.learning_package = content_api.get_learning_package_by_key(self.library.key) + self.learning_package = content_api.get_learning_package_by_ref(str(self.library.key)) with freeze_time(self.created_date): self.collection = content_api.create_collection( learning_package_id=self.learning_package.id, diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index bf91039b686b..90451da182d8 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -461,14 +461,14 @@ def create_library( # and also update its title/description in case they differ. content_api.update_learning_package( learning_package.id, - key=str(ref.library_key), + package_ref=str(ref.library_key), title=title, description=description, ) else: # We have to generate a new LearningPackage for this library. learning_package = content_api.create_learning_package( - key=str(ref.library_key), + package_ref=str(ref.library_key), title=title, description=description, ) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index 518ac5b31fe3..a1471ea65a8e 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -379,7 +379,7 @@ def get_component_version_asset(request, component_version_uuid, asset_path): # Permissions check... learning_package = component_version.component.learning_package - library_key = LibraryLocatorV2.from_string(learning_package.key) + library_key = LibraryLocatorV2.from_string(learning_package.package_ref) api.require_permission_for_library_key( library_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, ) diff --git a/openedx/core/djangoapps/xblock/api.py b/openedx/core/djangoapps/xblock/api.py index 0ab620db9ab3..211b14871e7f 100644 --- a/openedx/core/djangoapps/xblock/api.py +++ b/openedx/core/djangoapps/xblock/api.py @@ -199,7 +199,7 @@ def get_component_from_usage_key(usage_key: UsageKeyV2) -> Component: This is a lower-level function that will return a Component even if there is no current draft version of that Component (because it's been soft-deleted). """ - learning_package = content_api.get_learning_package_by_key( + learning_package = content_api.get_learning_package_by_ref( str(usage_key.context_key) ) return content_api.get_component_by_key( diff --git a/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py b/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py index 3a90fb27a5f7..65de9c77332b 100644 --- a/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py @@ -326,7 +326,7 @@ def _get_component_from_usage_key(self, usage_key): TODO: This is the third place where we're implementing this. Figure out where the definitive place should be and have everything else call that. """ - learning_package = content_api.get_learning_package_by_key(str(usage_key.lib_key)) + learning_package = content_api.get_learning_package_by_ref(str(usage_key.lib_key)) try: component = content_api.get_component_by_key( learning_package.id, From 0eafb4196eb342adc6d3014cd021de36c89279b3 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Tue, 21 Apr 2026 14:14:19 -0400 Subject: [PATCH 06/17] refactor: Rename PublishableEntity.key to entity_ref for openedx-core 0.43.0 Switches callers of get_publishable_entity_by_key (now _by_ref) to the new name, and renames .key attribute reads on PublishableEntity, Component, and Container (via PublishableEntityMixin) to .entity_ref. Query filters using key__in/entity__key become entity_ref__in/ entity__entity_ref. The set_library_item_collections param entity_key is renamed to entity_ref for consistency. Collection.key reads are intentionally left for the collection_code rename in a later commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- openedx/core/djangoapps/content/search/api.py | 2 +- .../djangoapps/content/search/documents.py | 2 +- .../content/search/tests/test_api.py | 2 +- .../djangoapps/content_libraries/api/blocks.py | 8 ++++---- .../content_libraries/api/collections.py | 18 +++++++++--------- .../api/container_metadata.py | 3 ++- .../content_libraries/api/containers.py | 4 ++-- .../content_libraries/rest_api/blocks.py | 2 +- .../content_libraries/rest_api/containers.py | 2 +- .../core/djangoapps/content_libraries/tasks.py | 6 +++--- .../content_libraries/tests/test_api.py | 2 +- 11 files changed, 26 insertions(+), 25 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index f6bfdf13be77..5c3c9e16b975 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -677,7 +677,7 @@ def index_container_batch(batch, num_done, library_key) -> int: doc.update(searchable_doc_containers(container_key, "sections")) docs.append(doc) except Exception as err: # pylint: disable=broad-except - status_cb(f"Error indexing container {container.key}: {err}") + status_cb(f"Error indexing container {container.entity_ref}: {err}") num_done += 1 if docs: diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index b986966ec42c..0ee3b4839287 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -450,7 +450,7 @@ def searchable_doc_collections(object_id: OpaqueKey) -> dict: component = lib_api.get_component_from_usage_key(object_id) collections = content_api.get_entity_collections( component.learning_package_id, - component.key, + component.entity_ref, ).values('key', 'title') elif isinstance(object_id, LibraryContainerLocator): container = lib_api.get_container(object_id, include_collections=True) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 3a4cdc32eafa..7f666ef071e8 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -536,7 +536,7 @@ def test_reindex_meilisearch_library_block_error(self, mock_meilisearch) -> None def mocked_from_component(lib_key, component): # Simulate an error when processing problem 1 - if component.key == 'xblock.v1:problem:p1': + if component.entity_ref == 'xblock.v1:problem:p1': raise Exception('Error') return orig_from_component(lib_key, component) diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 2ddc06fde254..d969f9edbe8c 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -178,7 +178,7 @@ def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=Fals if include_collections: associated_collections = content_api.get_entity_collections( component.learning_package_id, - component.key, + component.entity_ref, ).values('key', 'title') else: associated_collections = None @@ -725,7 +725,7 @@ def send_block_deleted_signal(): send_block_deleted_signal() raise - affected_collections = content_api.get_entity_collections(component.learning_package_id, component.key) + affected_collections = content_api.get_entity_collections(component.learning_package_id, component.entity_ref) affected_containers = get_containers_contains_item(usage_key) content_api.soft_delete_draft(component.id, deleted_by=user_id) @@ -770,7 +770,7 @@ def restore_library_block(usage_key: LibraryUsageLocatorV2, user_id: int | None """ component = get_component_from_usage_key(usage_key) library_key = usage_key.context_key - affected_collections = content_api.get_entity_collections(component.learning_package_id, component.key) + affected_collections = content_api.get_entity_collections(component.learning_package_id, component.entity_ref) # Set draft version back to the latest available component version id. content_api.set_draft_version( @@ -985,7 +985,7 @@ def publish_component_changes(usage_key: LibraryUsageLocatorV2, user_id: int): learning_package = content_library.learning_package assert learning_package # The core publishing API is based on draft objects, so find the draft that corresponds to this component: - drafts_to_publish = content_api.get_all_drafts(learning_package.id).filter(entity__key=component.key) + drafts_to_publish = content_api.get_all_drafts(learning_package.id).filter(entity__entity_ref=component.entity_ref) # Publish the component and update anything that needs to be updated (e.g. search index): publish_log = content_api.publish_from_drafts( learning_package.id, draft_qset=drafts_to_publish, published_by=user_id, diff --git a/openedx/core/djangoapps/content_libraries/api/collections.py b/openedx/core/djangoapps/content_libraries/api/collections.py index 9d011bdae363..175304119716 100644 --- a/openedx/core/djangoapps/content_libraries/api/collections.py +++ b/openedx/core/djangoapps/content_libraries/api/collections.py @@ -127,8 +127,8 @@ def update_library_collection_items( assert content_library.learning_package_id assert content_library.library_key == library_key - # Fetch the Component.key values for the provided UsageKeys. - item_keys = [] + # Fetch the Component.entity_ref values for the provided UsageKeys. + item_refs = [] for opaque_key in opaque_keys: if isinstance(opaque_key, LibraryContainerLocator): try: @@ -139,7 +139,7 @@ def update_library_collection_items( except Collection.DoesNotExist as exc: raise ContentLibraryContainerNotFound(opaque_key) from exc - item_keys.append(container.key) + item_refs.append(container.entity_ref) elif isinstance(opaque_key, UsageKeyV2): # Parse the block_family from the key to use as namespace. block_type = BlockTypeKey.from_string(str(opaque_key)) @@ -153,13 +153,13 @@ def update_library_collection_items( except Component.DoesNotExist as exc: raise ContentLibraryBlockNotFound(opaque_key) from exc - item_keys.append(component.key) + item_refs.append(component.entity_ref) else: # This should never happen, but just in case. raise ValueError(f"Invalid opaque_key: {opaque_key}") entities_qset = PublishableEntity.objects.filter( - key__in=item_keys, + entity_ref__in=item_refs, ) if remove: @@ -181,7 +181,7 @@ def update_library_collection_items( def set_library_item_collections( library_key: LibraryLocatorV2, - entity_key: str, + entity_ref: str, *, collection_keys: list[str], created_by: int | None = None, @@ -207,12 +207,12 @@ def set_library_item_collections( assert content_library.learning_package_id assert content_library.library_key == library_key - publishable_entity = content_api.get_publishable_entity_by_key( + publishable_entity = content_api.get_publishable_entity_by_ref( content_library.learning_package_id, - key=entity_key, + entity_ref=entity_ref, ) - # Note: Component.key matches its PublishableEntity.key + # Note: Component.entity_ref matches its PublishableEntity.entity_ref collection_qs = content_api.get_collections(content_library.learning_package_id).filter( key__in=collection_keys ) diff --git a/openedx/core/djangoapps/content_libraries/api/container_metadata.py b/openedx/core/djangoapps/content_libraries/api/container_metadata.py index 5bb8bcd50ae4..ead5c6f051d6 100644 --- a/openedx/core/djangoapps/content_libraries/api/container_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/container_metadata.py @@ -366,7 +366,8 @@ def library_container_locator( if container_type_code not in LIBRARY_ALLOWED_CONTAINER_TYPES: raise ValueError(f"Unsupported container type for content libraries: {container!r}") - return LibraryContainerLocator(library_key, container_type=container_type_code, container_id=container.key) + # TODO: verify whether container_id should use entity_ref (opaque) or container_code (local slug). + return LibraryContainerLocator(library_key, container_type=container_type_code, container_id=container.entity_ref) def get_container_from_key(container_key: LibraryContainerLocator, include_deleted=False) -> Container: diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 5db34dc753da..93d42b282717 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -226,7 +226,7 @@ def send_container_deleted_signal(): # Fetch related collections and containers before soft-delete affected_collections = content_api.get_entity_collections( container.publishable_entity.learning_package_id, - container.key, + container.entity_ref, ) affected_containers = get_containers_contains_item(container_key) # Get children containers or components to update their index data @@ -291,7 +291,7 @@ def restore_container(container_key: LibraryContainerLocator) -> None: affected_collections = content_api.get_entity_collections( container.publishable_entity.learning_package_id, - container.key, + container.entity_ref, ) content_api.set_draft_version(container.id, container.versioning.latest.pk) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index a1471ea65a8e..81fc6bd2f1b6 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -272,7 +272,7 @@ def patch(self, request: RestRequest, usage_key_str) -> Response: collection_keys = serializer.validated_data['collection_keys'] api.set_library_item_collections( library_key=key.lib_key, - entity_key=component.publishable_entity.key, + entity_ref=component.publishable_entity.entity_ref, collection_keys=collection_keys, created_by=request.user.id, content_library=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 04dde384361e..12a4132920c3 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -346,7 +346,7 @@ def patch(self, request: RestRequest, container_key: LibraryContainerLocator) -> collection_keys = serializer.validated_data['collection_keys'] api.set_library_item_collections( library_key=container_key.lib_key, - entity_key=container_key.container_id, + entity_ref=container_key.container_id, collection_keys=collection_keys, created_by=request.user.id, content_library=content_library, diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py index bb5db0a397c1..378ee9a19161 100644 --- a/openedx/core/djangoapps/content_libraries/tasks.py +++ b/openedx/core/djangoapps/content_libraries/tasks.py @@ -142,7 +142,7 @@ def send_events_after_publish(publish_log_pk: int, library_key_str: str) -> None pass else: log.warning( - f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation " + f"PublishableEntity {record.entity.pk} / {record.entity.entity_ref} was modified during publish operation " "but is of unknown type." ) @@ -246,13 +246,13 @@ def send_events_after_revert(draft_change_log_id: int, library_key_str: str) -> updated_container_keys.add(container_key) else: log.warning( - f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation " + f"PublishableEntity {record.entity.pk} / {record.entity.entity_ref} was modified during publish operation " "but is of unknown type." ) # If any collections contain this entity, their item count may need to be updated, e.g. if this was a # newly created component in the collection and is now deleted, or this was deleted and is now re-added. for parent_collection in content_api.get_entity_collections( - record.entity.learning_package_id, record.entity.key, + record.entity.learning_package_id, record.entity.entity_ref, ): collection_key = api.library_collection_locator( library_key=library_key, diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index 408d16618569..c62cf8eda861 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -318,7 +318,7 @@ def test_set_library_component_collections(self) -> None: component = api.get_component_from_usage_key(UsageKeyV2.from_string(self.lib2_problem_block["id"])) api.set_library_item_collections( library_key=self.lib2.library_key, - entity_key=component.publishable_entity.key, + entity_ref=component.publishable_entity.entity_ref, collection_keys=[self.col2.key, self.col3.key], ) From d1371c314b530d4f5a99f748683cb1e8d4e71db4 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Tue, 21 Apr 2026 14:16:09 -0400 Subject: [PATCH 07/17] refactor: Rename Component.local_key to component_code for openedx-core 0.43.0 Updates callers of get_component_by_key/component_exists_by_key (now _by_code) and switches the local_key kwarg on create_component, create_component_and_version, and related queries to component_code. Also renames component.local_key reads to component.component_code and adjusts a modulestore_migrator container query that filtered on publishable_entity__key (now entity_ref). Co-Authored-By: Claude Opus 4.7 (1M context) --- cms/djangoapps/modulestore_migrator/tasks.py | 8 ++++---- .../modulestore_migrator/tests/test_tasks.py | 12 ++++++------ .../core/djangoapps/content_libraries/api/blocks.py | 4 ++-- .../djangoapps/content_libraries/api/collections.py | 4 ++-- .../djangoapps/content_libraries/api/libraries.py | 2 +- .../djangoapps/content_libraries/library_context.py | 4 ++-- openedx/core/djangoapps/xblock/api.py | 4 ++-- .../xblock/runtime/openedx_content_runtime.py | 4 ++-- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/cms/djangoapps/modulestore_migrator/tasks.py b/cms/djangoapps/modulestore_migrator/tasks.py index b54b9191e6ba..95048b92feb2 100644 --- a/cms/djangoapps/modulestore_migrator/tasks.py +++ b/cms/djangoapps/modulestore_migrator/tasks.py @@ -346,13 +346,13 @@ def _import_structure( LibraryUsageLocatorV2(target_library.key, block_type, block_id) # type: ignore[abstract] for block_type, block_id in content_api.get_components(migration.target.id).values_list( - "component_type__name", "local_key" + "component_type__name", "component_code" ) ), used_container_slugs=set( content_api.get_containers( migration.target.id - ).values_list("publishable_entity__key", flat=True) + ).values_list("publishable_entity__entity_ref", flat=True) ), previous_block_migrations=( get_migration_blocks(source_data.previous_migration.pk) @@ -932,7 +932,7 @@ def _migrate_component( try: component = content_api.get_components(context.target_package_id).get( component_type=component_type, - local_key=target_key.block_id, + component_code=target_key.block_id, ) component_existed = True # Do we have a specific method for this? @@ -953,7 +953,7 @@ def _migrate_component( component = content_api.create_component( context.target_package_id, component_type=component_type, - local_key=target_key.block_id, + component_code=target_key.block_id, created=context.created_at, created_by=context.created_by, ) diff --git a/cms/djangoapps/modulestore_migrator/tests/test_tasks.py b/cms/djangoapps/modulestore_migrator/tests/test_tasks.py index 2285dd7d77e8..76f94da16bc2 100644 --- a/cms/djangoapps/modulestore_migrator/tests/test_tasks.py +++ b/cms/djangoapps/modulestore_migrator/tests/test_tasks.py @@ -718,7 +718,7 @@ def test_migrate_container_creates_new_container(self): component_type=content_api.get_or_create_component_type( "xblock.v1", "problem" ), - local_key="child_problem_1", + component_code="child_problem_1", created=timezone.now(), created_by=self.user.id, ) @@ -734,7 +734,7 @@ def test_migrate_container_creates_new_container(self): component_type=content_api.get_or_create_component_type( "xblock.v1", "html" ), - local_key="child_html_1", + component_code="child_html_1", created=timezone.now(), created_by=self.user.id, ) @@ -906,7 +906,7 @@ def test_migrate_container_preserves_child_order(self): component_type=content_api.get_or_create_component_type( "xblock.v1", "problem" ), - local_key=f"child_problem_{i}", + component_code=f"child_problem_{i}", created=timezone.now(), created_by=self.user.id, ) @@ -946,7 +946,7 @@ def test_migrate_container_with_mixed_child_types(self): component_type=content_api.get_or_create_component_type( "xblock.v1", "problem" ), - local_key="mixed_problem", + component_code="mixed_problem", created=timezone.now(), created_by=self.user.id, ) @@ -962,7 +962,7 @@ def test_migrate_container_with_mixed_child_types(self): component_type=content_api.get_or_create_component_type( "xblock.v1", "html" ), - local_key="mixed_html", + component_code="mixed_html", created=timezone.now(), created_by=self.user.id, ) @@ -978,7 +978,7 @@ def test_migrate_container_with_mixed_child_types(self): component_type=content_api.get_or_create_component_type( "xblock.v1", "video" ), - local_key="mixed_video", + component_code="mixed_video", created=timezone.now(), created_by=self.user.id, ) diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index d969f9edbe8c..f2f415dfa2e0 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -425,7 +425,7 @@ def _import_staged_block( component = content_api.create_component( # noqa: F841 learning_package.id, component_type=component_type, - local_key=usage_key.block_id, + component_code=usage_key.block_id, created=now, created_by=user.id, ) @@ -1046,7 +1046,7 @@ def _create_component_for_block( component, component_version = content_api.create_component_and_version( learning_package.id, component_type=component_type, - local_key=usage_key.block_id, + component_code=usage_key.block_id, title=display_name, created=now, created_by=user_id, diff --git a/openedx/core/djangoapps/content_libraries/api/collections.py b/openedx/core/djangoapps/content_libraries/api/collections.py index 175304119716..6ab2a98c563b 100644 --- a/openedx/core/djangoapps/content_libraries/api/collections.py +++ b/openedx/core/djangoapps/content_libraries/api/collections.py @@ -144,11 +144,11 @@ def update_library_collection_items( # Parse the block_family from the key to use as namespace. block_type = BlockTypeKey.from_string(str(opaque_key)) try: - component = content_api.get_component_by_key( + component = content_api.get_component_by_code( content_library.learning_package_id, namespace=block_type.block_family, type_name=opaque_key.block_type, - local_key=opaque_key.block_id, + component_code=opaque_key.block_id, ) except Component.DoesNotExist as exc: raise ContentLibraryBlockNotFound(opaque_key) from exc diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index 90451da182d8..68d4258bb065 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -718,7 +718,7 @@ def library_component_usage_key( return LibraryUsageLocatorV2( # type: ignore[abstract] library_key, block_type=component.component_type.name, - usage_id=component.local_key, + usage_id=component.component_code, ) diff --git a/openedx/core/djangoapps/content_libraries/library_context.py b/openedx/core/djangoapps/content_libraries/library_context.py index bd91a3f89250..5d2c25c4286e 100644 --- a/openedx/core/djangoapps/content_libraries/library_context.py +++ b/openedx/core/djangoapps/content_libraries/library_context.py @@ -95,11 +95,11 @@ def block_exists(self, usage_key: LibraryUsageLocatorV2): if learning_package is None: return False - return content_api.component_exists_by_key( + return content_api.component_exists_by_code( learning_package.id, namespace='xblock.v1', type_name=usage_key.block_type, - local_key=usage_key.block_id, + component_code=usage_key.block_id, ) def send_block_updated_event(self, usage_key: UsageKeyV2): diff --git a/openedx/core/djangoapps/xblock/api.py b/openedx/core/djangoapps/xblock/api.py index 211b14871e7f..cc684e72b13f 100644 --- a/openedx/core/djangoapps/xblock/api.py +++ b/openedx/core/djangoapps/xblock/api.py @@ -202,11 +202,11 @@ def get_component_from_usage_key(usage_key: UsageKeyV2) -> Component: learning_package = content_api.get_learning_package_by_ref( str(usage_key.context_key) ) - return content_api.get_component_by_key( + return content_api.get_component_by_code( learning_package.id, namespace='xblock.v1', type_name=usage_key.block_type, - local_key=usage_key.block_id, + component_code=usage_key.block_id, ) diff --git a/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py b/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py index 65de9c77332b..53d2267e92a4 100644 --- a/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py @@ -328,11 +328,11 @@ def _get_component_from_usage_key(self, usage_key): """ learning_package = content_api.get_learning_package_by_ref(str(usage_key.lib_key)) try: - component = content_api.get_component_by_key( + component = content_api.get_component_by_code( learning_package.id, namespace='xblock.v1', type_name=usage_key.block_type, - local_key=usage_key.block_id, + component_code=usage_key.block_id, ) except ObjectDoesNotExist as exc: raise NoSuchUsage(usage_key) from exc From 1a3fc08ac3f7c637a423184878fe641216ee3bd0 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Tue, 21 Apr 2026 14:18:06 -0400 Subject: [PATCH 08/17] refactor: Rename Container key and ComponentVersionMedia.key for openedx-core 0.43.0 Switches get_container_by_key to get_container_by_code and renames the container creation kwarg from key to container_code for both create_container_and_version and its platform wrapper. Updates ComponentVersionMedia accesses: the model field .key becomes .path, and create_component_version_media's key kwarg becomes path. Co-Authored-By: Claude Opus 4.7 (1M context) --- cms/djangoapps/modulestore_migrator/tasks.py | 2 +- .../core/djangoapps/content_libraries/api/blocks.py | 10 +++++----- .../djangoapps/content_libraries/api/collections.py | 4 ++-- .../content_libraries/api/container_metadata.py | 2 +- .../djangoapps/content_libraries/api/containers.py | 2 +- .../djangoapps/content_libraries/rest_api/blocks.py | 2 +- .../xblock/runtime/openedx_content_runtime.py | 6 +++--- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cms/djangoapps/modulestore_migrator/tasks.py b/cms/djangoapps/modulestore_migrator/tasks.py index 95048b92feb2..701a638b909b 100644 --- a/cms/djangoapps/modulestore_migrator/tasks.py +++ b/cms/djangoapps/modulestore_migrator/tasks.py @@ -971,7 +971,7 @@ def _migrate_component( continue new_path = f"static/{filename}" content_api.create_component_version_media( - component_version.pk, media_pk, key=new_path + component_version.pk, media_pk, path=new_path ) # Publish the component diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index f2f415dfa2e0..0476bd8829ac 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -490,7 +490,7 @@ def _import_staged_block( content_api.create_component_version_media( component_version.pk, content.id, - key=filename, + path=filename, ) # Emit library block created event @@ -852,7 +852,7 @@ def get_library_block_static_asset_files(usage_key: LibraryUsageLocatorV2) -> li component_version .componentversionmedia_set .filter(media__has_file=True) - .order_by('key') + .order_by('path') .select_related('media') ) @@ -860,13 +860,13 @@ def get_library_block_static_asset_files(usage_key: LibraryUsageLocatorV2) -> li return [ LibraryXBlockStaticFile( - path=cvm.key, + path=cvm.path, size=cvm.media.size, url=site_root_url + reverse( 'content_libraries:library-assets', kwargs={ 'component_version_uuid': component_version.uuid, - 'asset_path': cvm.key, + 'asset_path': cvm.path, } ), ) @@ -1061,7 +1061,7 @@ def _create_component_for_block( content_api.create_component_version_media( component_version.pk, content.id, - key="block.xml", + path="block.xml", ) return component_version diff --git a/openedx/core/djangoapps/content_libraries/api/collections.py b/openedx/core/djangoapps/content_libraries/api/collections.py index 6ab2a98c563b..e2efc5d250fa 100644 --- a/openedx/core/djangoapps/content_libraries/api/collections.py +++ b/openedx/core/djangoapps/content_libraries/api/collections.py @@ -132,9 +132,9 @@ def update_library_collection_items( for opaque_key in opaque_keys: if isinstance(opaque_key, LibraryContainerLocator): try: - container = content_api.get_container_by_key( + container = content_api.get_container_by_code( content_library.learning_package_id, - key=opaque_key.container_id, + container_code=opaque_key.container_id, ) except Collection.DoesNotExist as exc: raise ContentLibraryContainerNotFound(opaque_key) from exc diff --git a/openedx/core/djangoapps/content_libraries/api/container_metadata.py b/openedx/core/djangoapps/content_libraries/api/container_metadata.py index ead5c6f051d6..a0a73deea6cd 100644 --- a/openedx/core/djangoapps/content_libraries/api/container_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/container_metadata.py @@ -380,7 +380,7 @@ def get_container_from_key(container_key: LibraryContainerLocator, include_delet content_library = ContentLibrary.objects.get_by_key(container_key.lib_key) learning_package = content_library.learning_package assert learning_package is not None - container = content_api.get_container_by_key(learning_package.id, key=container_key.container_id) + container = content_api.get_container_by_code(learning_package.id, container_code=container_key.container_id) assert content_api.get_container_type_code_of(container) in LIBRARY_ALLOWED_CONTAINER_TYPES # We only return the container if it exists and either: # 1. the container has a draft version (which means it is not soft-deleted) OR diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 93d42b282717..e33ef2fa373e 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -121,7 +121,7 @@ def create_container( # Then try creating the actual container: container, _initial_version = content_api.create_container_and_version( content_library.learning_package_id, - key=slug, + container_code=slug, title=title, container_cls=container_cls, entities=[], diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index 81fc6bd2f1b6..8dd70a069edb 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -402,7 +402,7 @@ def get_component_version_asset(request, component_version_uuid, asset_path): return redirect_response # If we got here, we know that the asset exists and it's okay to download. - cv_media = component_version.componentversionmedia_set.get(key=asset_path) + cv_media = component_version.componentversionmedia_set.get(path=asset_path) media = cv_media.media # Delete the re-direct part of the response headers. We'll copy the rest. diff --git a/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py b/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py index 53d2267e92a4..92caecccd32b 100644 --- a/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py @@ -251,13 +251,13 @@ def get_block_assets(self, block, fetch_asset_data): .componentversionmedia_set .filter(media__has_file=True) .select_related('media') - .order_by('key') + .order_by('path') ) return [ StaticFile( - name=cvm.key, - url=self._absolute_url_for_asset(component_version, cvm.key), + name=cvm.path, + url=self._absolute_url_for_asset(component_version, cvm.path), data=cvm.media.read_file().read() if fetch_asset_data else None, ) for cvm in cvm_list From 4df1a2e1125b3d991f82fca314a941368d630361 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Tue, 21 Apr 2026 14:22:03 -0400 Subject: [PATCH 09/17] refactor: Rename Collection.key and update backup/restore for openedx-core 0.43.0 Renames all Collection APIs that took key/collection_key to use collection_code: create_collection, update_collection, delete_collection, restore_collection, add_to_collection, etc. Switches Collection.key attribute reads to .collection_code across tests, signal handlers, search indexers, and modulestore_migrator. Filters like target_collection__key become target_collection__collection_code. Also updates the library restore serializer to track the renamed lp_restored_data fields: archive_org_key -> archive_org_code, archive_slug -> archive_package_code, key -> package_ref, archive_lp_key -> archive_package_ref. The archive_org_code and archive_package_code fields now allow None, since openedx-core no longer raises ValueError when the archive_package_ref cannot be parsed as {prefix}:{org_code}:{package_code}. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rest_api/v1/views/tests/test_home.py | 2 +- .../modulestore_migrator/api/read_api.py | 6 +-- cms/djangoapps/modulestore_migrator/tasks.py | 2 +- .../modulestore_migrator/tests/test_api.py | 10 ++-- openedx/core/djangoapps/content/search/api.py | 4 +- .../djangoapps/content/search/documents.py | 8 ++-- .../content/search/tests/test_api.py | 18 +++---- .../content/search/tests/test_documents.py | 4 +- .../content_libraries/api/blocks.py | 4 +- .../content_libraries/api/collections.py | 8 ++-- .../content_libraries/api/containers.py | 4 +- .../content_libraries/rest_api/collections.py | 2 +- .../content_libraries/rest_api/serializers.py | 10 ++-- .../content_libraries/signal_handlers.py | 6 +-- .../djangoapps/content_libraries/tasks.py | 2 +- .../content_libraries/tests/test_api.py | 48 +++++++++---------- .../tests/test_containers.py | 4 +- .../tests/test_views_collections.py | 42 ++++++++-------- 18 files changed, 93 insertions(+), 91 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py index bebcdfd53dc5..7ac9e8e479ee 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py @@ -274,7 +274,7 @@ def setUp(self): collection_key = "test-collection" content_api.create_collection( learning_package_id=learning_package.id, - key=collection_key, + collection_code=collection_key, title="Test Collection", created_by=self.user.id, ) diff --git a/cms/djangoapps/modulestore_migrator/api/read_api.py b/cms/djangoapps/modulestore_migrator/api/read_api.py index e62a684476f6..9b5aa0b8b8ef 100644 --- a/cms/djangoapps/modulestore_migrator/api/read_api.py +++ b/cms/djangoapps/modulestore_migrator/api/read_api.py @@ -137,7 +137,7 @@ def get_migrations( if target_key: migrations = migrations.filter(target__key=str(target_key)) if target_collection_slug: - migrations = migrations.filter(target_collection__key=target_collection_slug) + migrations = migrations.filter(target_collection__collection_code=target_collection_slug) if task_uuid: migrations = migrations.filter(task_status__uuid=task_uuid) if is_failed is not None: @@ -176,9 +176,9 @@ def _migration(m: models.ModulestoreMigration) -> ModulestoreMigration: return ModulestoreMigration( pk=m.id, source_key=m.source.key, - target_key=LibraryLocatorV2.from_string(m.target.key), + target_key=LibraryLocatorV2.from_string(m.target.package_ref), target_title=m.target.title, - target_collection_slug=(m.target_collection.key if m.target_collection else None), + target_collection_slug=(m.target_collection.collection_code if m.target_collection else None), target_collection_title=(m.target_collection.title if m.target_collection else None), is_failed=m.is_failed, task_uuid=m.task_status.uuid, diff --git a/cms/djangoapps/modulestore_migrator/tasks.py b/cms/djangoapps/modulestore_migrator/tasks.py index 701a638b909b..c878d75d2c6c 100644 --- a/cms/djangoapps/modulestore_migrator/tasks.py +++ b/cms/djangoapps/modulestore_migrator/tasks.py @@ -409,7 +409,7 @@ def _populate_collection(user_id: int, migration: models.ModulestoreMigration) - if block_target_pks: content_api.add_to_collection( learning_package_id=migration.target.pk, - key=migration.target_collection.key, + collection_code=migration.target_collection.collection_code, entities_qset=PublishableEntity.objects.filter(id__in=block_target_pks), created_by=user_id, ) diff --git a/cms/djangoapps/modulestore_migrator/tests/test_api.py b/cms/djangoapps/modulestore_migrator/tests/test_api.py index 311d2b5b69ea..7e88e031a1ad 100644 --- a/cms/djangoapps/modulestore_migrator/tests/test_api.py +++ b/cms/djangoapps/modulestore_migrator/tests/test_api.py @@ -232,7 +232,7 @@ def test_start_migration_to_library_with_collection(self): collection_key = "test-collection" content_api.create_collection( learning_package_id=self.learning_package.id, - key=collection_key, + collection_code=collection_key, title="Test Collection", created_by=user.id, ) @@ -249,7 +249,7 @@ def test_start_migration_to_library_with_collection(self): ) modulestoremigration = ModulestoreMigration.objects.get() - assert modulestoremigration.target_collection.key == collection_key + assert modulestoremigration.target_collection.collection_code == collection_key def test_start_migration_to_library_with_strategy_skip(self): """ @@ -487,19 +487,19 @@ def test_migration_api_for_various_scenarios(self): # Lib 2 has Collection C content_api.create_collection( learning_package_id=self.learning_package.id, - key="test-collection-1a", + collection_code="test-collection-1a", title="Test Collection A in Lib 1", created_by=user.id, ) content_api.create_collection( learning_package_id=self.learning_package.id, - key="test-collection-1b", + collection_code="test-collection-1b", title="Test Collection B in Lib 1", created_by=user.id, ) content_api.create_collection( learning_package_id=self.learning_package_2.id, - key="test-collection-2c", + collection_code="test-collection-2c", title="Test Collection C in Lib 2", created_by=user.id, ) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 5c3c9e16b975..2d044cd4210d 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -641,7 +641,7 @@ def index_collection_batch(batch, num_done, library_key) -> int: docs = [] for collection in batch: try: - collection_key = lib_api.library_collection_locator(library_key, collection.key) + collection_key = lib_api.library_collection_locator(library_key, collection.collection_code) doc = searchable_doc_for_collection(collection_key, collection=collection) doc.update(searchable_doc_tags(collection_key)) docs.append(doc) @@ -1032,7 +1032,7 @@ def upsert_content_library_index_docs(library_key: LibraryLocatorV2, full_index: docs.append(doc) for collection in lib_api.get_library_collections(library_key): - collection_key = lib_api.library_collection_locator(library_key, collection.key) + collection_key = lib_api.library_collection_locator(library_key, collection.collection_code) doc = searchable_doc_for_collection(collection_key, collection=collection) docs.append(doc) diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 0ee3b4839287..a98a02764bbe 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -33,7 +33,7 @@ class Fields: usage_key = "usage_key" type = "type" # DocType.course_block or DocType.library_block (see below) # The block_id part of the usage key for course or library blocks. - # If it's a collection, the collection.key is stored here. + # If it's a collection, the collection.collection_code is stored here. # Sometimes human-readable, sometimes a random hex ID # Is only unique within the given context_key. block_id = "block_id" @@ -64,7 +64,7 @@ class Fields: tags_level2 = "level2" tags_level3 = "level3" # Collections (dictionary) that this object belongs to. - # Similarly to tags above, we collect the collection.titles and collection.keys into hierarchical facets. + # Similarly to tags above, we collect the collection.titles and collection.collection_codes into hierarchical facets. collections = "collections" collections_display_name = "display_name" collections_key = "key" @@ -543,7 +543,7 @@ def searchable_doc_for_collection( pass if collection: - assert collection.key == collection_key.collection_id + assert collection.collection_code == collection_key.collection_id draft_num_children = content_api.filter_publishable_entities( collection.entities, @@ -558,7 +558,7 @@ def searchable_doc_for_collection( Fields.context_key: str(collection_key.context_key), Fields.org: str(collection_key.org), Fields.usage_key: str(collection_key), - Fields.block_id: collection.key, + Fields.block_id: collection.collection_code, Fields.type: DocType.collection, Fields.display_name: collection.title, Fields.description: collection.description, diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 7f666ef071e8..981bb5858b80 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -192,7 +192,7 @@ def setUp(self) -> None: with freeze_time(self.created_date): self.collection = content_api.create_collection( learning_package_id=self.learning_package.id, - key="MYCOL", + collection_code="MYCOL", title="my_collection", created_by=None, description="my collection description" @@ -202,7 +202,7 @@ def setUp(self) -> None: ) self.collection_dict = { "id": "lib-collectionorg1libmycol-5b647617", - "block_id": self.collection.key, + "block_id": self.collection.collection_code, "usage_key": str(self.collection_key), "type": "collection", "display_name": "my_collection", @@ -722,7 +722,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch) -> None: for collection in (collection2, collection1): library_api.update_library_collection_items( self.library.key, - collection_key=collection.key, + collection_key=collection.collection_code, opaque_keys=[ self.problem1.usage_key, ], @@ -904,7 +904,7 @@ def test_delete_collection(self, mock_meilisearch) -> None: with freeze_time(updated_date): library_api.update_library_collection_items( self.library.key, - collection_key=self.collection.key, + collection_key=self.collection.collection_code, opaque_keys=[ self.problem1.usage_key, self.unit.container_key @@ -918,14 +918,14 @@ def test_delete_collection(self, mock_meilisearch) -> None: "id": self.doc_problem1["id"], "collections": { "display_name": [self.collection.title], - "key": [self.collection.key], + "key": [self.collection.collection_code], }, } doc_unit_with_collection = { "id": self.unit_dict["id"], "collections": { "display_name": [self.collection.title], - "key": [self.collection.key], + "key": [self.collection.collection_code], }, } @@ -944,7 +944,7 @@ def test_delete_collection(self, mock_meilisearch) -> None: # Soft-delete the collection content_api.delete_collection( self.collection.learning_package_id, - self.collection.key, + self.collection.collection_code, ) doc_problem_without_collection = { @@ -979,7 +979,7 @@ def test_delete_collection(self, mock_meilisearch) -> None: with freeze_time(restored_date): content_api.restore_collection( self.collection.learning_package_id, - self.collection.key, + self.collection.collection_code, ) doc_collection = copy.deepcopy(self.collection_dict) @@ -1001,7 +1001,7 @@ def test_delete_collection(self, mock_meilisearch) -> None: # Hard-delete the collection content_api.delete_collection( self.collection.learning_package_id, - self.collection.key, + self.collection.collection_code, hard_delete=True, ) diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index a9aea3ab3cfb..ee4a1d613f61 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -492,7 +492,7 @@ def test_collection_with_library(self): assert doc == { "id": "lib-collectionedx2012_falltoy_collection-d1d907a4", - "block_id": self.collection.key, + "block_id": self.collection.collection_code, "usage_key": str(self.collection_key), "type": "collection", "org": "edX", @@ -521,7 +521,7 @@ def test_collection_with_published_library(self): assert doc == { "id": "lib-collectionedx2012_falltoy_collection-d1d907a4", - "block_id": self.collection.key, + "block_id": self.collection.collection_code, "usage_key": str(self.collection_key), "type": "collection", "org": "edX", diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 0476bd8829ac..63327280d021 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -743,7 +743,7 @@ def send_block_deleted_signal(): library_collection=LibraryCollectionData( collection_key=library_collection_locator( library_key=library_key, - collection_key=collection.key, + collection_key=collection.collection_code, ), background=True, ) @@ -809,7 +809,7 @@ def restore_library_block(usage_key: LibraryUsageLocatorV2, user_id: int | None library_collection=LibraryCollectionData( collection_key=library_collection_locator( library_key=library_key, - collection_key=collection.key, + collection_key=collection.collection_code, ), background=True, ) diff --git a/openedx/core/djangoapps/content_libraries/api/collections.py b/openedx/core/djangoapps/content_libraries/api/collections.py index e2efc5d250fa..41cf425c111c 100644 --- a/openedx/core/djangoapps/content_libraries/api/collections.py +++ b/openedx/core/djangoapps/content_libraries/api/collections.py @@ -54,7 +54,7 @@ def create_library_collection( try: collection = content_api.create_collection( learning_package_id=content_library.learning_package_id, - key=collection_key, + collection_code=collection_key, title=title, description=description, created_by=created_by, @@ -86,7 +86,7 @@ def update_library_collection( try: collection = content_api.update_collection( learning_package_id=content_library.learning_package_id, - key=collection_key, + collection_code=collection_key, title=title, description=description, ) @@ -214,7 +214,7 @@ def set_library_item_collections( # Note: Component.entity_ref matches its PublishableEntity.entity_ref collection_qs = content_api.get_collections(content_library.learning_package_id).filter( - key__in=collection_keys + collection_code__in=collection_keys ) affected_collections = content_api.set_collections( @@ -232,7 +232,7 @@ def set_library_item_collections( library_collection=LibraryCollectionData( collection_key=library_collection_locator( library_key=library_key, - collection_key=collection.key, + collection_key=collection.collection_code, ), background=True, ) diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index e33ef2fa373e..d357cdbe78f7 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -249,7 +249,7 @@ def send_container_deleted_signal(): library_collection=LibraryCollectionData( collection_key=library_collection_locator( library_key=library_key, - collection_key=collection.key, + collection_key=collection.collection_code, ), background=True, ) @@ -333,7 +333,7 @@ def restore_container(container_key: LibraryContainerLocator) -> None: library_collection=LibraryCollectionData( collection_key=library_collection_locator( library_key=library_key, - collection_key=collection.key, + collection_key=collection.collection_code, ), ) ) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/collections.py b/openedx/core/djangoapps/content_libraries/rest_api/collections.py index 3f67f5e777a8..4ccbae2ba36b 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/collections.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/collections.py @@ -184,7 +184,7 @@ def destroy(self, request: RestRequest, *args, **kwargs) -> Response: assert collection.learning_package_id content_api.delete_collection( collection.learning_package_id, - collection.key, + collection.collection_code, hard_delete=False, ) return Response(None, 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 72c59f695833..b30906d58d89 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -447,14 +447,16 @@ class RestoreSuccessDataSerializer(serializers.Serializer): """ learning_package_id = serializers.IntegerField(source="lp_restored_data.id") title = serializers.CharField(source="lp_restored_data.title") - org = serializers.CharField(source="lp_restored_data.archive_org_key") - slug = serializers.CharField(source="lp_restored_data.archive_slug") + # archive_org_code and archive_package_code may be None when archive_package_ref cannot be parsed + # as {prefix}:{org_code}:{package_code} (previously this raised ValueError in openedx-core). + org = serializers.CharField(source="lp_restored_data.archive_org_code", allow_null=True) + slug = serializers.CharField(source="lp_restored_data.archive_package_code", allow_null=True) # The `key` is a unique temporary key assigned to the learning package during the restore process, # whereas the `archive_key` is the original key of the learning package from the backup. # The temporary learning package key is replaced with a standard key once it is added to a content library. - key = serializers.CharField(source="lp_restored_data.key") - archive_key = serializers.CharField(source="lp_restored_data.archive_lp_key") + key = serializers.CharField(source="lp_restored_data.package_ref") + archive_key = serializers.CharField(source="lp_restored_data.archive_package_ref") containers = serializers.IntegerField(source="lp_restored_data.num_containers") components = serializers.IntegerField(source="lp_restored_data.num_components") diff --git a/openedx/core/djangoapps/content_libraries/signal_handlers.py b/openedx/core/djangoapps/content_libraries/signal_handlers.py index 041a49b473e0..fada1cb2f874 100644 --- a/openedx/core/djangoapps/content_libraries/signal_handlers.py +++ b/openedx/core/djangoapps/content_libraries/signal_handlers.py @@ -43,7 +43,7 @@ def library_collection_saved(sender, instance, created, **kwargs): library_collection=LibraryCollectionData( collection_key=library_collection_locator( library_key=library.library_key, - collection_key=instance.key, + collection_key=instance.collection_code, ), ) ) @@ -54,7 +54,7 @@ def library_collection_saved(sender, instance, created, **kwargs): library_collection=LibraryCollectionData( collection_key=library_collection_locator( library_key=library.library_key, - collection_key=instance.key, + collection_key=instance.collection_code, ), ) ) @@ -77,7 +77,7 @@ def library_collection_deleted(sender, instance, **kwargs): library_collection=LibraryCollectionData( collection_key=library_collection_locator( library_key=library.library_key, - collection_key=instance.key, + collection_key=instance.collection_code, ), ) ) diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py index 378ee9a19161..2fe28c7476a8 100644 --- a/openedx/core/djangoapps/content_libraries/tasks.py +++ b/openedx/core/djangoapps/content_libraries/tasks.py @@ -651,7 +651,7 @@ def restore_library(self, user_id, storage_path): TASK_LOGGER.info( 'Restored learning package (id: %s) with key %s', learning_package_data.get('id'), - learning_package_data.get('key') + learning_package_data.get('package_ref') ) # Save the restore details as an artifact in JSON format diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index c62cf8eda861..7d2e84c10280 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -115,7 +115,7 @@ def test_create_library_collection(self) -> None: description="Description for Collection 4", created_by=self.user.id, ) - assert collection.key == "COL4" + assert collection.collection_code == "COL4" assert collection.title == "Collection 4" assert collection.description == "Description for Collection 4" assert collection.created_by == self.user @@ -150,10 +150,10 @@ def test_update_library_collection(self) -> None: self.col1 = api.update_library_collection( self.lib1.library_key, - self.col1.key, + self.col1.collection_code, title="New title for Collection 1", ) - assert self.col1.key == "COL1" + assert self.col1.collection_code == "COL1" assert self.col1.title == "New title for Collection 1" assert self.col1.description == "Description for Collection 1" assert self.col1.created_by == self.user @@ -177,7 +177,7 @@ def test_update_library_collection_wrong_library(self) -> None: with self.assertRaises(api.ContentLibraryCollectionNotFound) as exc: # noqa: F841, PT027 api.update_library_collection( self.lib1.library_key, - self.col2.key, + self.col2.collection_code, ) def test_delete_library_collection(self) -> None: @@ -187,7 +187,7 @@ def test_delete_library_collection(self) -> None: assert self.lib1.learning_package_id is not None content_api.delete_collection( self.lib1.learning_package_id, - self.col1.key, + self.col1.collection_code, hard_delete=True, ) @@ -211,7 +211,7 @@ def test_update_library_collection_items(self) -> None: self.col1 = api.update_library_collection_items( self.lib1.library_key, - self.col1.key, + self.col1.collection_code, opaque_keys=[ LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), @@ -222,7 +222,7 @@ def test_update_library_collection_items(self) -> None: self.col1 = api.update_library_collection_items( self.lib1.library_key, - self.col1.key, + self.col1.collection_code, opaque_keys=[ LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), ], @@ -240,7 +240,7 @@ def test_update_library_collection_components_event(self) -> None: api.update_library_collection_items( self.lib1.library_key, - self.col1.key, + self.col1.collection_code, opaque_keys=[ LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), @@ -300,7 +300,7 @@ def test_update_collection_components_from_wrong_library(self) -> None: with self.assertRaises(api.ContentLibraryBlockNotFound) as exc: # noqa: PT027 api.update_library_collection_items( self.lib2.library_key, - self.col2.key, + self.col2.collection_code, opaque_keys=[ LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), @@ -319,12 +319,12 @@ def test_set_library_component_collections(self) -> None: api.set_library_item_collections( library_key=self.lib2.library_key, entity_ref=component.publishable_entity.entity_ref, - collection_keys=[self.col2.key, self.col3.key], + collection_keys=[self.col2.collection_code, self.col3.collection_code], ) assert self.lib2.learning_package_id is not None - assert len(content_api.get_collection(self.lib2.learning_package_id, self.col2.key).entities.all()) == 1 - assert len(content_api.get_collection(self.lib2.learning_package_id, self.col3.key).entities.all()) == 1 + assert len(content_api.get_collection(self.lib2.learning_package_id, self.col2.collection_code).entities.all()) == 1 + assert len(content_api.get_collection(self.lib2.learning_package_id, self.col3.collection_code).entities.all()) == 1 self.assertDictContainsEntries( event_receiver.call_args_list[0].kwargs, @@ -343,11 +343,11 @@ def test_set_library_component_collections(self) -> None: assert all(event["signal"] == LIBRARY_COLLECTION_UPDATED for event in collection_update_events) assert {event["library_collection"] for event in collection_update_events} == { LibraryCollectionData( - collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col2.key), + collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col2.collection_code), background=True, ), LibraryCollectionData( - collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col3.key), + collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col3.collection_code), background=True, ) } @@ -355,7 +355,7 @@ def test_set_library_component_collections(self) -> None: def test_delete_library_block(self) -> None: api.update_library_collection_items( self.lib1.library_key, - self.col1.key, + self.col1.collection_code, opaque_keys=[ LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), @@ -376,7 +376,7 @@ def test_delete_library_block(self) -> None: "library_collection": LibraryCollectionData( collection_key=api.library_collection_locator( self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), background=True, ), @@ -386,7 +386,7 @@ def test_delete_library_block(self) -> None: def test_delete_library_container(self) -> None: api.update_library_collection_items( self.lib1.library_key, - self.col1.key, + self.col1.collection_code, opaque_keys=[ LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), @@ -415,7 +415,7 @@ def test_delete_library_container(self) -> None: "library_collection": LibraryCollectionData( collection_key=api.library_collection_locator( self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), background=True, ), @@ -499,7 +499,7 @@ def test_delete_library_block_when_component_does_not_exist(self) -> None: def test_restore_library_block(self) -> None: api.update_library_collection_items( self.lib1.library_key, - self.col1.key, + self.col1.collection_code, opaque_keys=[ LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), @@ -520,7 +520,7 @@ def test_restore_library_block(self) -> None: "library_collection": LibraryCollectionData( collection_key=api.library_collection_locator( self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), background=True, ), @@ -539,7 +539,7 @@ def test_add_component_and_revert(self) -> None: # Add component. Note: collections are not part of the draft/publish cycle so this is not a draft change. api.update_library_collection_items( self.lib1.library_key, - self.col1.key, + self.col1.collection_code, opaque_keys=[ LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), LibraryUsageLocatorV2.from_string(new_problem_block["id"]), @@ -560,7 +560,7 @@ def test_add_component_and_revert(self) -> None: "library_collection": LibraryCollectionData( collection_key=api.library_collection_locator( self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), ), }, @@ -574,7 +574,7 @@ def test_delete_component_and_revert(self) -> None: # Add components and publish api.update_library_collection_items( self.lib1.library_key, - self.col1.key, + self.col1.collection_code, opaque_keys=[ LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]) @@ -599,7 +599,7 @@ def test_delete_component_and_revert(self) -> None: "library_collection": LibraryCollectionData( collection_key=api.library_collection_locator( self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), ), }, diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 37ad621d26e6..a95b238b78a4 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -591,7 +591,7 @@ def test_unit_collections(self) -> None: result = self._patch_container_collections( self.unit["id"], - collection_keys=[col1.key], + collection_keys=[col1.collection_code], ) assert result['count'] == 1 @@ -600,7 +600,7 @@ def test_unit_collections(self) -> None: unit_as_read = self._get_container(self.unit["id"]) # Verify the collections - assert unit_as_read['collections'] == [{"title": col1.title, "key": col1.key}] + assert unit_as_read['collections'] == [{"title": col1.title, "key": col1.collection_code}] def test_section_hierarchy(self): with self.assertNumQueries(126): diff --git a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py index 270580cb2a61..a25b18dc3777 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py @@ -88,7 +88,7 @@ def test_get_library_collection(self): Test retrieving a Content Library Collection """ resp = self.client.get( - URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.collection_code) ) # Check that correct Content Library Collection data retrieved @@ -103,7 +103,7 @@ def test_get_library_collection(self): random_user = UserFactory.create(username="Random", email="random@example.com") with self.as_user(random_user): resp = self.client.get( - URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.collection_code) ) assert resp.status_code == 403 @@ -113,7 +113,7 @@ def test_get_invalid_library_collection(self): """ # Fetch collection that belongs to a different library, it should fail resp = self.client.get( - URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key=self.col3.key) + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key=self.col3.collection_code) ) assert resp.status_code == 404 @@ -249,7 +249,7 @@ def test_create_invalid_library_collection(self): # Create collection with an existing collection.key; it should fail post_data_existing_key = { - "key": self.col1.key, + "key": self.col1.collection_code, "title": "Collection 4", } resp = self.client.post( @@ -275,7 +275,7 @@ def test_update_library_collection(self): "title": "Collection 3 Updated", } resp = self.client.patch( - URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key), + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.collection_code), patch_data, format="json" ) @@ -297,7 +297,7 @@ def test_update_library_collection(self): "title": "Collection 3 should not update", } resp = self.client.patch( - URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key), + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.collection_code), patch_data, format="json" ) @@ -313,7 +313,7 @@ def test_update_invalid_library_collection(self): } # Update collection that belongs to a different library, it should fail resp = self.client.patch( - URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key=self.col3.key), + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key=self.col3.collection_code), patch_data, format="json" ) @@ -331,7 +331,7 @@ def test_update_invalid_library_collection(self): # Update collection with invalid library_key provided, it should fail resp = self.client.patch( - URL_LIB_COLLECTION.format(lib_key=123, collection_key=self.col3.key), + URL_LIB_COLLECTION.format(lib_key=123, collection_key=self.col3.collection_code), patch_data, format="json" ) @@ -342,22 +342,22 @@ def test_delete_library_collection(self): Test soft-deleting and restoring a Content Library Collection """ resp = self.client.delete( - URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.collection_code) ) assert resp.status_code == 204 resp = self.client.get( - URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.collection_code) ) assert resp.status_code == 404 resp = self.client.post( - URL_LIB_COLLECTION_RESTORE.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + URL_LIB_COLLECTION_RESTORE.format(lib_key=self.lib2.library_key, collection_key=self.col3.collection_code) ) assert resp.status_code == 204 resp = self.client.get( - URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.collection_code) ) # Check that correct Content Library Collection data retrieved expected_collection = { @@ -375,7 +375,7 @@ def test_get_components(self): resp = self.client.get( URL_LIB_COLLECTION_COMPONENTS.format( lib_key=self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), ) assert resp.status_code == 405 @@ -388,7 +388,7 @@ def test_update_components(self): resp = self.client.patch( URL_LIB_COLLECTION_COMPONENTS.format( lib_key=self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), data={ "usage_keys": [ @@ -404,7 +404,7 @@ def test_update_components(self): resp = self.client.delete( URL_LIB_COLLECTION_COMPONENTS.format( lib_key=self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), data={ "usage_keys": [ @@ -423,7 +423,7 @@ def test_update_containers(self): resp = self.client.patch( URL_LIB_COLLECTION_COMPONENTS.format( lib_key=self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), data={ "usage_keys": [ @@ -440,7 +440,7 @@ def test_update_containers(self): resp = self.client.delete( URL_LIB_COLLECTION_COMPONENTS.format( lib_key=self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), data={ "usage_keys": [ @@ -460,7 +460,7 @@ def test_update_components_wrong_collection(self, method): resp = getattr(self.client, method)( URL_LIB_COLLECTION_COMPONENTS.format( lib_key=self.lib2.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), data={ "usage_keys": [ @@ -478,7 +478,7 @@ def test_update_components_missing_data(self, method): resp = getattr(self.client, method)( URL_LIB_COLLECTION_COMPONENTS.format( lib_key=self.lib2.library_key, - collection_key=self.col3.key, + collection_key=self.col3.collection_code, ), ) assert resp.status_code == 400 @@ -494,7 +494,7 @@ def test_update_components_from_another_library(self, method): resp = getattr(self.client, method)( URL_LIB_COLLECTION_COMPONENTS.format( lib_key=self.lib2.library_key, - collection_key=self.col3.key, + collection_key=self.col3.collection_code, ), data={ "usage_keys": [ @@ -515,7 +515,7 @@ def test_update_components_permissions(self, method): resp = getattr(self.client, method)( URL_LIB_COLLECTION_COMPONENTS.format( lib_key=self.lib1.library_key, - collection_key=self.col1.key, + collection_key=self.col1.collection_code, ), ) assert resp.status_code == 403 From f139dc79fd76d20f5564a1130349adce7dac3f67 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Tue, 21 Apr 2026 20:25:09 -0400 Subject: [PATCH 10/17] fix: various fixes to Claude's output... fix: build locator with container_code fix: pylint and mypy fix: queries for search index fix: some missed cvm.key -> cvm.path fix: undo breaking library changes fix: openedx-core no longer raises integrityerror on conflict fix: misses in modulestore_migrator fix: search tests docs: Improve collection_code/key TODO comment --- .../modulestore_migrator/api/read_api.py | 2 +- .../rest_api/v1/serializers.py | 4 +++ .../modulestore_migrator/rest_api/v1/views.py | 2 +- cms/djangoapps/modulestore_migrator/tasks.py | 2 +- .../modulestore_migrator/tests/test_tasks.py | 10 ++++---- .../djangoapps/content/search/documents.py | 9 +++++-- .../content/search/tests/test_api.py | 16 ++++++------ .../content_libraries/api/blocks.py | 7 ++++-- .../content_libraries/api/collections.py | 25 +++++++++---------- .../api/container_metadata.py | 6 ++--- .../content_libraries/api/containers.py | 6 ++++- .../content_libraries/rest_api/collections.py | 5 +++- .../content_libraries/rest_api/serializers.py | 5 +++- .../djangoapps/content_libraries/tasks.py | 12 ++++----- .../content_libraries/tests/test_api.py | 14 ++++++++--- .../tests/test_views_collections.py | 2 +- .../video_config/transcripts_utils.py | 2 +- openedx/core/djangoapps/xblock/api.py | 4 +-- .../xblock/runtime/openedx_content_runtime.py | 6 ++--- 19 files changed, 83 insertions(+), 56 deletions(-) diff --git a/cms/djangoapps/modulestore_migrator/api/read_api.py b/cms/djangoapps/modulestore_migrator/api/read_api.py index 9b5aa0b8b8ef..cd9962c4aa19 100644 --- a/cms/djangoapps/modulestore_migrator/api/read_api.py +++ b/cms/djangoapps/modulestore_migrator/api/read_api.py @@ -135,7 +135,7 @@ def get_migrations( if source_key: migrations = migrations.filter(source__key=source_key) if target_key: - migrations = migrations.filter(target__key=str(target_key)) + migrations = migrations.filter(target__package_ref=str(target_key)) if target_collection_slug: migrations = migrations.filter(target_collection__collection_code=target_collection_slug) if task_uuid: diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py index 797c42d9a5b3..9dc0c5dda5d3 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py @@ -18,6 +18,10 @@ class LibraryMigrationCollectionSerializer(serializers.ModelSerializer): """ Serializer for the target collection of a library migration. """ + # Expose Collection.collection_code as "key" to preserve the REST API field name. + # This is temporary: https://github.com/openedx/openedx-platform/issues/38406 + key = serializers.CharField(source='collection_code') + class Meta: model = Collection fields = ["key", "title"] diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py index 0b76c01cdaaa..594c9518a2ae 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py @@ -540,7 +540,7 @@ def get_queryset(self): self.request.user, lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY ) - queryset = queryset.filter(target__key=library_key, source__key__startswith='course-v1') + queryset = queryset.filter(target__package_ref=str(library_key), source__key__startswith='course-v1') return queryset diff --git a/cms/djangoapps/modulestore_migrator/tasks.py b/cms/djangoapps/modulestore_migrator/tasks.py index c878d75d2c6c..15b25524b871 100644 --- a/cms/djangoapps/modulestore_migrator/tasks.py +++ b/cms/djangoapps/modulestore_migrator/tasks.py @@ -867,7 +867,7 @@ def _migrate_container( container_exists = False if PublishableEntity.objects.filter( learning_package_id=context.target_package_id, - key=target_key.container_id, + entity_ref=target_key.container_id, ).exists(): libraries_api.restore_container(container_key=target_key) container = libraries_api.get_container(target_key) diff --git a/cms/djangoapps/modulestore_migrator/tests/test_tasks.py b/cms/djangoapps/modulestore_migrator/tests/test_tasks.py index 76f94da16bc2..ae4ad1548937 100644 --- a/cms/djangoapps/modulestore_migrator/tests/test_tasks.py +++ b/cms/djangoapps/modulestore_migrator/tests/test_tasks.py @@ -89,12 +89,12 @@ def setUp(self): ) self.collection = Collection.objects.create( learning_package=self.learning_package, - key="test_collection", + collection_code="test_collection", title="Test Collection", ) self.collection2 = Collection.objects.create( learning_package=self.learning_package, - key="test_collection2", + collection_code="test_collection2", title="Test Collection 2", ) @@ -426,7 +426,7 @@ def test_migrate_component_with_static_content(self): self.assertIsNone(reason) # noqa: PT009 component_media = result.componentversion.componentversionmedia_set.filter( - key="static/test_image.png" + path="static/test_image.png" ).first() self.assertIsNotNone(component_media) # noqa: PT009 self.assertEqual(component_media.media.id, test_media.id) # noqa: PT009 @@ -673,12 +673,12 @@ def test_migrate_component_content_filename_not_in_olx(self): referenced_content_exists = ( result.componentversion.componentversionmedia_set.filter( - key="static/referenced.png" + path="static/referenced.png" ).exists() ) unreferenced_content_exists = ( result.componentversion.componentversionmedia_set.filter( - key="static/unreferenced.png" + path="static/unreferenced.png" ).exists() ) diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index a98a02764bbe..678281191eeb 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -7,6 +7,7 @@ from hashlib import blake2b from django.core.exceptions import ObjectDoesNotExist +from django.db.models import F from django.utils.text import slugify from opaque_keys.edx.keys import ContainerKey, LearningContextKey, OpaqueKey, UsageKey from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator @@ -64,7 +65,8 @@ class Fields: tags_level2 = "level2" tags_level3 = "level3" # Collections (dictionary) that this object belongs to. - # Similarly to tags above, we collect the collection.titles and collection.collection_codes into hierarchical facets. + # Similarly to tags above, we collect the collection.titles and collection.collection_codes + # into hierarchical facets. collections = "collections" collections_display_name = "display_name" collections_key = "key" @@ -448,10 +450,13 @@ def searchable_doc_collections(object_id: OpaqueKey) -> dict: try: if isinstance(object_id, UsageKey): component = lib_api.get_component_from_usage_key(object_id) + # Temporarily alias collection_code to "key" so downstream consumers + # (search indexer, REST API) keep the same field name. We will update + # downstream consumers later: https://github.com/openedx/openedx-platform/issues/38406 collections = content_api.get_entity_collections( component.learning_package_id, component.entity_ref, - ).values('key', 'title') + ).values("title", key=F('collection_code')) elif isinstance(object_id, LibraryContainerLocator): container = lib_api.get_container(object_id, include_collections=True) collections = container.collections diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 981bb5858b80..afa847748e89 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -732,8 +732,8 @@ def test_index_library_block_and_collections(self, mock_meilisearch) -> None: lib_access, _ = SearchAccess.objects.get_or_create(context_key=self.library.key) doc_collection1_created = { "id": "lib-collectionorg1libcol1-283a79c9", - "block_id": collection1.key, - "usage_key": f"lib-collection:org1:lib:{collection1.key}", + "block_id": collection1.collection_code, + "usage_key": f"lib-collection:org1:lib:{collection1.collection_code}", "type": "collection", "display_name": "Collection 1", "description": "First Collection", @@ -750,8 +750,8 @@ def test_index_library_block_and_collections(self, mock_meilisearch) -> None: } doc_collection2_created = { "id": "lib-collectionorg1libcol2-46823d4d", - "block_id": collection2.key, - "usage_key": f"lib-collection:org1:lib:{collection2.key}", + "block_id": collection2.collection_code, + "usage_key": f"lib-collection:org1:lib:{collection2.collection_code}", "type": "collection", "display_name": "Collection 2", "description": "Second Collection", @@ -768,8 +768,8 @@ def test_index_library_block_and_collections(self, mock_meilisearch) -> None: } doc_collection2_updated = { "id": "lib-collectionorg1libcol2-46823d4d", - "block_id": collection2.key, - "usage_key": f"lib-collection:org1:lib:{collection2.key}", + "block_id": collection2.collection_code, + "usage_key": f"lib-collection:org1:lib:{collection2.collection_code}", "type": "collection", "display_name": "Collection 2", "description": "Second Collection", @@ -786,8 +786,8 @@ def test_index_library_block_and_collections(self, mock_meilisearch) -> None: } doc_collection1_updated = { "id": "lib-collectionorg1libcol1-283a79c9", - "block_id": collection1.key, - "usage_key": f"lib-collection:org1:lib:{collection1.key}", + "block_id": collection1.collection_code, + "usage_key": f"lib-collection:org1:lib:{collection1.collection_code}", "type": "collection", "display_name": "Collection 1", "description": "First Collection", diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 63327280d021..f8d45b78d5fe 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -15,7 +15,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import validate_unicode_slug from django.db import transaction -from django.db.models import QuerySet +from django.db.models import F, QuerySet from django.urls import reverse from django.utils.text import slugify from django.utils.translation import gettext as _ @@ -176,10 +176,13 @@ def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=Fals raise ContentLibraryBlockNotFound(usage_key) if include_collections: + # Temporarily alias collection_code to "key" so downstream consumers + # (search indexer, REST API) keep the same field name. We will update + # downstream consumers later: https://github.com/openedx/openedx-platform/issues/38406 associated_collections = content_api.get_entity_collections( component.learning_package_id, component.entity_ref, - ).values('key', 'title') + ).values("title", key=F('collection_code')) else: associated_collections = None xblock_metadata = LibraryXBlockMetadata.from_component( diff --git a/openedx/core/djangoapps/content_libraries/api/collections.py b/openedx/core/djangoapps/content_libraries/api/collections.py index 41cf425c111c..1c87acd21232 100644 --- a/openedx/core/djangoapps/content_libraries/api/collections.py +++ b/openedx/core/djangoapps/content_libraries/api/collections.py @@ -2,7 +2,6 @@ Python API for library collections ================================== """ -from django.db import IntegrityError from opaque_keys import OpaqueKey from opaque_keys.edx.keys import BlockTypeKey, UsageKeyV2 from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator, LibraryLocatorV2 @@ -51,18 +50,18 @@ def create_library_collection( assert content_library.learning_package_id assert content_library.library_key == library_key - try: - collection = content_api.create_collection( - learning_package_id=content_library.learning_package_id, - collection_code=collection_key, - title=title, - description=description, - created_by=created_by, - ) - except IntegrityError as err: - raise LibraryCollectionAlreadyExists from err - - return collection + if Collection.objects.filter( + learning_package_id=content_library.learning_package_id, + collection_code=collection_key, + ).exists(): + raise LibraryCollectionAlreadyExists(f"Collection {collection_key} already exists in {library_key}") + return content_api.create_collection( + learning_package_id=content_library.learning_package_id, + collection_code=collection_key, + title=title, + description=description, + created_by=created_by, + ) def update_library_collection( diff --git a/openedx/core/djangoapps/content_libraries/api/container_metadata.py b/openedx/core/djangoapps/content_libraries/api/container_metadata.py index a0a73deea6cd..98a5024ac674 100644 --- a/openedx/core/djangoapps/content_libraries/api/container_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/container_metadata.py @@ -365,9 +365,9 @@ def library_container_locator( container_type_code = content_api.get_container_type_code_of(container) if container_type_code not in LIBRARY_ALLOWED_CONTAINER_TYPES: raise ValueError(f"Unsupported container type for content libraries: {container!r}") - - # TODO: verify whether container_id should use entity_ref (opaque) or container_code (local slug). - return LibraryContainerLocator(library_key, container_type=container_type_code, container_id=container.entity_ref) + return LibraryContainerLocator( + library_key, container_type=container_type_code, container_id=container.container_code, + ) def get_container_from_key(container_key: LibraryContainerLocator, include_deleted=False) -> Container: diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index d357cdbe78f7..0b126e5665df 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -10,6 +10,7 @@ from uuid import uuid4 from django.db import transaction +from django.db.models import F from django.utils.text import slugify from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content import api as content_api @@ -73,10 +74,13 @@ def get_container( """ container = get_container_from_key(container_key) if include_collections: + # Temporarily alias collection_code to "key" so downstream consumers + # (search indexer, REST API) keep the same field name. We will update + # downstream consumers later: https://github.com/openedx/openedx-platform/issues/38406 associated_collections = content_api.get_entity_collections( container.publishable_entity.learning_package_id, container_key.container_id, - ).values("key", "title") + ).values("title", key=F("collection_code")) else: associated_collections = None container_meta = ContainerMetadata.from_container( diff --git a/openedx/core/djangoapps/content_libraries/rest_api/collections.py b/openedx/core/djangoapps/content_libraries/rest_api/collections.py index 4ccbae2ba36b..9875f31d79d5 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/collections.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/collections.py @@ -33,7 +33,10 @@ class LibraryCollectionsView(ModelViewSet): """ serializer_class = ContentLibraryCollectionSerializer - lookup_field = 'key' + # URL kwarg is `key` for backwards compatibility. + # https://github.com/openedx/openedx-platform/issues/38406 + lookup_field = 'collection_code' + lookup_url_kwarg = 'key' def __init__(self, *args, **kwargs) -> None: """ diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index b30906d58d89..425bb75c10d0 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -284,10 +284,13 @@ class ContentLibraryCollectionSerializer(serializers.ModelSerializer): """ Serializer for a Content Library Collection """ + # Expose Collection.collection_code as "key" to preserve the REST API field name. + # https://github.com/openedx/openedx-platform/issues/38406 + key = serializers.CharField(source='collection_code') class Meta: model = Collection - fields = '__all__' + exclude = ['collection_code'] class ContentLibraryCollectionUpdateSerializer(serializers.Serializer): diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py index 2fe28c7476a8..ee972b248649 100644 --- a/openedx/core/djangoapps/content_libraries/tasks.py +++ b/openedx/core/djangoapps/content_libraries/tasks.py @@ -142,8 +142,8 @@ def send_events_after_publish(publish_log_pk: int, library_key_str: str) -> None pass else: log.warning( - f"PublishableEntity {record.entity.pk} / {record.entity.entity_ref} was modified during publish operation " - "but is of unknown type." + f"PublishableEntity {record.entity.pk} / {record.entity.entity_ref} " + "was modified during publish operation but is of unknown type." ) for container_key in affected_containers: @@ -246,8 +246,8 @@ def send_events_after_revert(draft_change_log_id: int, library_key_str: str) -> updated_container_keys.add(container_key) else: log.warning( - f"PublishableEntity {record.entity.pk} / {record.entity.entity_ref} was modified during publish operation " - "but is of unknown type." + f"PublishableEntity {record.entity.pk} / {record.entity.entity_ref} " + "was modified during publish operation but is of unknown type." ) # If any collections contain this entity, their item count may need to be updated, e.g. if this was a # newly created component in the collection and is now deleted, or this was deleted and is now re-added. @@ -256,7 +256,7 @@ def send_events_after_revert(draft_change_log_id: int, library_key_str: str) -> ): collection_key = api.library_collection_locator( library_key=library_key, - collection_key=parent_collection.key, + collection_key=parent_collection.collection_code, ) affected_collection_keys.add(collection_key) @@ -541,7 +541,7 @@ def backup_library(self, user_id: int, library_key_str: str) -> None: file_path = os.path.join(root_dir, filename) user = User.objects.get(id=user_id) origin_server = getattr(settings, 'CMS_BASE', None) - create_lib_zip_file(lp_key=str(library_key), path=file_path, user=user, origin_server=origin_server) + create_lib_zip_file(package_ref=str(library_key), path=file_path, user=user, origin_server=origin_server) set_custom_attribute("exporting_completed", str(library_key)) with open(file_path, 'rb') as zipfile: diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index 7d2e84c10280..cc0564964fb8 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -323,8 +323,10 @@ def test_set_library_component_collections(self) -> None: ) assert self.lib2.learning_package_id is not None - assert len(content_api.get_collection(self.lib2.learning_package_id, self.col2.collection_code).entities.all()) == 1 - assert len(content_api.get_collection(self.lib2.learning_package_id, self.col3.collection_code).entities.all()) == 1 + col2 = content_api.get_collection(self.lib2.learning_package_id, self.col2.collection_code) + col3 = content_api.get_collection(self.lib2.learning_package_id, self.col3.collection_code) + assert len(col2.entities.all()) == 1 + assert len(col3.entities.all()) == 1 self.assertDictContainsEntries( event_receiver.call_args_list[0].kwargs, @@ -343,11 +345,15 @@ def test_set_library_component_collections(self) -> None: assert all(event["signal"] == LIBRARY_COLLECTION_UPDATED for event in collection_update_events) assert {event["library_collection"] for event in collection_update_events} == { LibraryCollectionData( - collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col2.collection_code), + collection_key=api.library_collection_locator( + self.lib2.library_key, collection_key=self.col2.collection_code, + ), background=True, ), LibraryCollectionData( - collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col3.collection_code), + collection_key=api.library_collection_locator( + self.lib2.library_key, collection_key=self.col3.collection_code, + ), background=True, ) } diff --git a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py index a25b18dc3777..9f8e5dc1cde3 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py @@ -247,7 +247,7 @@ def test_create_invalid_library_collection(self): assert resp.status_code == 400 - # Create collection with an existing collection.key; it should fail + # Create collection with an existing collection.collection_code; it should fail post_data_existing_key = { "key": self.col1.collection_code, "title": "Collection 4", diff --git a/openedx/core/djangoapps/video_config/transcripts_utils.py b/openedx/core/djangoapps/video_config/transcripts_utils.py index c76a2b8b0377..6e6f0fde7b8c 100644 --- a/openedx/core/djangoapps/video_config/transcripts_utils.py +++ b/openedx/core/djangoapps/video_config/transcripts_utils.py @@ -1016,7 +1016,7 @@ def get_transcript_from_openedx_content(video_block, language, output_format, tr .componentversionmedia_set .filter(media__has_file=True) .select_related('media') - .get(key=file_path) + .get(path=file_path) .media ) data = media.read_file().read() diff --git a/openedx/core/djangoapps/xblock/api.py b/openedx/core/djangoapps/xblock/api.py index cc684e72b13f..a4661f67b164 100644 --- a/openedx/core/djangoapps/xblock/api.py +++ b/openedx/core/djangoapps/xblock/api.py @@ -232,9 +232,9 @@ def get_block_olx( raise NoSuchUsage(usage_key) # TODO: we should probably make a method on ComponentVersion that returns - # a content based on the name. Accessing by componentversionmedia__key is + # a content based on the name. Accessing by componentversionmedia__path is # awkward. - content = component_version.media.get(componentversionmedia__key="block.xml") + content = component_version.media.get(componentversionmedia__path="block.xml") return content.text diff --git a/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py b/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py index 92caecccd32b..1da6f048b1cc 100644 --- a/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/openedx_content_runtime.py @@ -191,7 +191,7 @@ def get_block(self, usage_key, for_parent=None, *, version: int | LatestVersion raise NoSuchUsage(usage_key) content = component_version.media.get( - componentversionmedia__key="block.xml" + componentversionmedia__path="block.xml" ) xml_node = etree.fromstring(content.text) block_type = usage_key.block_type @@ -447,7 +447,7 @@ def _lookup_asset_url(self, block: XBlock, asset_path: str) -> str | None: component_version .componentversionmedia_set .filter(media__has_file=True) - .get(key=f"static/{asset_path}") + .get(path=f"static/{asset_path}") ) except ObjectDoesNotExist: try: @@ -458,7 +458,7 @@ def _lookup_asset_url(self, block: XBlock, asset_path: str) -> str | None: component_version .componentversionmedia_set .filter(media__has_file=True) - .get(key=f"static/{asset_path}") + .get(path=f"static/{asset_path}") ) except ObjectDoesNotExist: # This means we see a path that _looks_ like it should be a static From e41a0a2e8f4941462051501619ee145b226bce45 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Wed, 22 Apr 2026 11:54:47 -0400 Subject: [PATCH 11/17] fix: Return 'unknown/unknown' for un-parseable org/lib archive slugs This is necessary because we are no longer presuming that package_ref follows the same format as a Content Library. In the future, we may want a more graceful way of handling this. --- .../content_libraries/rest_api/serializers.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index 425bb75c10d0..f8dd8b18a839 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -450,14 +450,14 @@ class RestoreSuccessDataSerializer(serializers.Serializer): """ learning_package_id = serializers.IntegerField(source="lp_restored_data.id") title = serializers.CharField(source="lp_restored_data.title") - # archive_org_code and archive_package_code may be None when archive_package_ref cannot be parsed - # as {prefix}:{org_code}:{package_code} (previously this raised ValueError in openedx-core). - org = serializers.CharField(source="lp_restored_data.archive_org_code", allow_null=True) - slug = serializers.CharField(source="lp_restored_data.archive_package_code", allow_null=True) - - # The `key` is a unique temporary key assigned to the learning package during the restore process, - # whereas the `archive_key` is the original key of the learning package from the backup. - # The temporary learning package key is replaced with a standard key once it is added to a content library. + org = serializers.SerializerMethodField() + slug = serializers.SerializerMethodField() + + # The `package_ref` is a unique temporary key assigned to the learning + # package during the restore process, whereas the `archive_package_ref` is + # the original key of the learning package from the backup. The temporary + # learning package_ref is replaced with a standard key once it is added to a + # content library. key = serializers.CharField(source="lp_restored_data.package_ref") archive_key = serializers.CharField(source="lp_restored_data.archive_package_ref") @@ -472,6 +472,18 @@ class RestoreSuccessDataSerializer(serializers.Serializer): created_at = serializers.DateTimeField(source="backup_metadata.created_at", format=DATETIME_FORMAT) created_by = serializers.SerializerMethodField() + def get_org(self, obj) -> str: + """ + The org code/slug, as parsed from archive_package_ref, or "unknown" if unparseable. + """ + return obj["lp_restored_data"]["archive_org_code"] or "unknown" + + def get_slug(self, obj) -> str: + """ + The library code/slug, as parsed from archive_package_ref, or "unknown" if unparseable. + """ + return obj["lp_restored_data"]["archive_package_code"] or "unknown" + def get_created_by(self, obj): """ Get the user information of the archive creator, if available. From bdbff725d53588b2b77b5314204c508d6f9ee27c Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Tue, 21 Apr 2026 19:37:55 -0600 Subject: [PATCH 12/17] feat: check authz permission on xblock outline --- cms/djangoapps/contentstore/views/block.py | 5 +- .../contentstore/views/tests/test_block.py | 124 ++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index e606477ee222..8aa821d98108 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -11,6 +11,7 @@ from django.utils.translation import gettext as _ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.http import require_http_methods +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from opaque_keys.edx.keys import CourseKey from web_fragments.fragment import Fragment @@ -28,6 +29,8 @@ from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access from common.djangoapps.util.json_request import JsonResponse, expect_json from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled +from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission from openedx.core.lib.xblock_utils import hash_resource, request_token, wrap_xblock, wrap_xblock_aside from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.x_module import ( # lint-amnesty, pylint: disable=wrong-import-order @@ -329,7 +332,7 @@ def xblock_outline_handler(request, usage_key_string): a course. """ usage_key = usage_key_with_run(usage_key_string) - if not has_studio_read_access(request.user, usage_key.course_key): + if not user_has_course_permission(request.user, COURSES_VIEW_COURSE.identifier, usage_key.course_key, LegacyAuthoringPermission.READ): raise PermissionDenied() response_format = request.GET.get("format", "html") diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index bb1206169189..cbd1cc82b11d 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -9,6 +9,7 @@ import ddt from bs4 import BeautifulSoup from django.conf import settings +from django.core.exceptions import PermissionDenied from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory @@ -54,9 +55,13 @@ from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from common.test.utils import assert_dict_contains_subset from lms.djangoapps.lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE +from openedx_authz.constants.roles import COURSE_STAFF from xmodule.course_block import DEFAULT_START_DATE from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -3486,6 +3491,125 @@ def validate_xblock_info_consistency( self.assertIsNone(xblock_info.get("child_info", None)) # noqa: PT009 +class TestXBlockOutlineHandlerAuthz(CourseAuthoringAuthzTestMixin, ItemTest): + """ + Unit tests for xblock_outline_handler authorization functionality. + """ + + def setUp(self): + super().setUp() + user_id = self.user.id + self.chapter = BlockFactory.create( + parent_location=self.course.location, + category="chapter", + display_name="Week 1", + user_id=user_id, + ) + self.sequential = BlockFactory.create( + parent_location=self.chapter.location, + category="sequential", + display_name="Lesson 1", + user_id=user_id, + ) + self.vertical = BlockFactory.create( + parent_location=self.sequential.location, + category="vertical", + display_name="Unit 1", + user_id=user_id, + ) + # Assign COURSE_STAFF role to authorized_user for the course + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_STAFF.external_key, + self.course.id + ) + + def test_authorized_user_gets_json_response(self): + """ + Test that authorized user gets JSON response from xblock_outline_handler. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + + self.client.login(username=self.authorized_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 200 + json_response = json.loads(resp.content.decode("utf-8")) + assert "id" in json_response + assert "display_name" in json_response + assert "child_info" in json_response + + def test_unauthorized_user_gets_permission_denied(self): + """ + Test that unauthorized user gets 403 response from xblock_outline_handler. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + + self.client.login(username=self.unauthorized_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 403 + + def test_superuser_gets_json_response(self): + """ + Test that superuser gets JSON response from xblock_outline_handler. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + + self.client.login(username=self.super_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 200 + json_response = json.loads(resp.content.decode("utf-8")) + assert "id" in json_response + assert "display_name" in json_response + assert "child_info" in json_response + + def test_staff_user_gets_json_response(self): + """ + Test that staff user gets JSON response from xblock_outline_handler. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + + self.client.login(username=self.staff_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 200 + json_response = json.loads(resp.content.decode("utf-8")) + assert "id" in json_response + assert "display_name" in json_response + assert "child_info" in json_response + + def test_authorized_chapter_outline(self): + """ + Test that authorized user can access chapter-level outline. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.chapter.location) + + self.client.login(username=self.authorized_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 200 + json_response = json.loads(resp.content.decode("utf-8")) + assert json_response["display_name"] == "Week 1" + assert "child_info" in json_response + # Verify that children are included (should have the sequential) + children = json_response["child_info"]["children"] + assert len(children) > 0 + assert children[0]["display_name"] == "Lesson 1" + + def test_unauthorized_chapter_outline(self): + """ + Test that unauthorized user cannot access chapter-level outline. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.chapter.location) + + self.client.login(username=self.unauthorized_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 403 + + class TestGetMetadataWithProblemDefaults(ModuleStoreTestCase): """ Unit tests for _get_metadata_with_problem_defaults. From 7e0f991f165372bf94205748ffc1b34f28eb89d1 Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Tue, 21 Apr 2026 22:26:18 -0600 Subject: [PATCH 13/17] feat: check authz permissions for course tagging --- .../core/djangoapps/content_tagging/auth.py | 28 ++- .../rest_api/v1/serializers.py | 53 ++++- .../rest_api/v1/tests/test_views.py | 189 +++++++++++++++++- .../content_tagging/rest_api/v1/views.py | 65 +++++- 4 files changed, 325 insertions(+), 10 deletions(-) diff --git a/openedx/core/djangoapps/content_tagging/auth.py b/openedx/core/djangoapps/content_tagging/auth.py index 24a64edc131a..1458f0e81348 100644 --- a/openedx/core/djangoapps/content_tagging/auth.py +++ b/openedx/core/djangoapps/content_tagging/auth.py @@ -5,11 +5,13 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_authz import api as authz_api -from openedx_authz.constants.permissions import COURSES_EXPORT_TAGS +from openedx_authz.constants.permissions import COURSES_EXPORT_TAGS, COURSES_MANAGE_TAGS, COURSES_VIEW_COURSE from openedx_tagging import rules as oel_tagging_rules from openedx.core import toggles as core_toggles +from .utils import get_context_key_from_key_string log = logging.getLogger(__name__) @@ -39,3 +41,27 @@ def has_view_object_tags_access(user, object_id): # The obj arg expects a model, but we are passing an object oel_tagging_rules.ObjectTagPermissionItem(taxonomy=None, object_id=object_id), # type: ignore[arg-type] ) + + +def should_use_authz_for_object(object_id) -> tuple[bool, CourseKey | None]: + """ + Check if openedx-authz should be used for the given object based on the context key and toggle. + + Returns (should_use_authz, course_key) where: + - should_use_authz: True if authz should be used, False otherwise + - course_key: The CourseKey if object is a course, None otherwise + """ + # Extract context_key and ensure it is a course_key + try: + context_key = get_context_key_from_key_string(object_id) + if not isinstance(context_key, CourseKey) or isinstance(context_key, LibraryLocatorV2): + return False, None + except (ValueError, AttributeError): + return False, None + + # Check if toggle is active + if not core_toggles.enable_authz_course_authoring(context_key): + return False, context_key + + # Authz should be used for this course object + return True, context_key diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py index e15f04504a3e..cf9bae0b7ba3 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py @@ -6,12 +6,17 @@ from openedx_tagging.rest_api.v1.serializers import ( ObjectTagMinimalSerializer, + ObjectTagsByTaxonomySerializer, + ObjectTagSerializer, TaxonomyListQueryParamsSerializer, TaxonomySerializer, ) from organizations.models import Organization from rest_framework import fields, serializers +from openedx_authz import api as authz_api +from openedx_authz.constants.permissions import COURSES_MANAGE_TAGS +from ...auth import should_use_authz_for_object from ...models import TaxonomyOrg @@ -95,12 +100,35 @@ class Meta: read_only_fields = ["orgs", "all_orgs"] +class ObjectTagOrgByTaxonomySerializer(ObjectTagsByTaxonomySerializer): + """ + Extend ObjectTagsByTaxonomySerializer to conditionally use openedx-authz for can_tag_object. + """ + + def can_tag_object(self, obj_tag) -> bool | None: + """ + Check if the user is authorized to tag the provided object. + Conditionally use openedx-authz for course objects with the toggle enabled. + """ + should_use_authz, course_key = should_use_authz_for_object(obj_tag.object_id) + if should_use_authz: + request = self.context.get('request') + if request and hasattr(request, 'user'): + return authz_api.is_user_allowed( + request.user.username, COURSES_MANAGE_TAGS.identifier, str(course_key) + ) + return False + + # Fall back to parent implementation + return super().can_tag_object(obj_tag) + + class ObjectTagCopiedMinimalSerializer(ObjectTagMinimalSerializer): """ Serializer for Object Tags. - This override `get_can_delete_objecttag` to avoid delete - object tags if is copied. + This overrides `can_delete_object_tag` to avoid deleting + object tags if they are copied and to conditionally use openedx-authz. """ is_copied = serializers.BooleanField(read_only=True) @@ -108,14 +136,25 @@ class ObjectTagCopiedMinimalSerializer(ObjectTagMinimalSerializer): class Meta(ObjectTagMinimalSerializer.Meta): fields = ObjectTagMinimalSerializer.Meta.fields + ["is_copied"] - def get_can_delete_objecttag(self, instance): + def can_delete_object_tag(self, instance) -> bool | None: """ - Verify if the user can delete the object tag. - - Override to return `False` if the object tag is copied. + Check if the user is authorized to delete the provided tag. + + Override to return `False` if the object tag is copied, + and conditionally use openedx-authz for course objects with the toggle enabled. """ if instance.is_copied: # The user can't delete copied tags. return False - return super().get_can_delete_objecttag(instance) + should_use_authz, course_key = should_use_authz_for_object(instance.object_id) + if should_use_authz: + request = self.context.get('request') + if request and hasattr(request, 'user'): + return authz_api.is_user_allowed( + request.user.username, COURSES_MANAGE_TAGS.identifier, str(course_key) + ) + return False + + # Fall back to parent implementation + return super().can_delete_object_tag(instance) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index 35f209f8a7e1..1b7c2708cd53 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -17,7 +17,7 @@ from edx_django_utils.cache import RequestCache from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryCollectionLocator, LibraryContainerLocator from openedx_authz.constants import permissions as authz_permissions -from openedx_authz.constants.roles import COURSE_STAFF +from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_STAFF from openedx_tagging.models import Tag, Taxonomy from openedx_tagging.models.system_defined import SystemDefinedTaxonomy from openedx_tagging.rest_api.v1.serializers import TaxonomySerializer @@ -2136,6 +2136,193 @@ def test_superuser_allowed(self): resp = client.get(self.get_url(self.course_key)) self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + +@skip_unless_cms +class TestObjectTagOrgViewWithAuthz(CourseAuthzTestMixin, SharedModuleStoreTestCase, APITestCase): + """ + Test ObjectTagOrgView with authz permissions. + """ + + authz_roles_to_assign = [COURSE_STAFF.external_key] + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + cls.course_key = cls.course.id + + def setUp(self): + super().setUp() + + # Create another course for cross-course scoping tests + self.other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.authorized_user.id) + self.other_course_key = self.other_course.id + + # Create taxonomy + self.taxonomy = tagging_api.create_taxonomy( + name="Test Taxonomy", + description="Test taxonomy for authz", + ) + TaxonomyOrg.objects.create( + taxonomy=self.taxonomy, + org=None, # Global taxonomy not tied to any org + rel_type=TaxonomyOrg.RelType.OWNER, + ) + + # Create tags + self.tag1 = Tag.objects.create( + taxonomy=self.taxonomy, + value="Tag 1", + ) + self.tag2 = Tag.objects.create( + taxonomy=self.taxonomy, + value="Tag 2", + ) + + # Create auditor user with view-only permissions + self.auditor_user = UserFactory(password=self.password) + self.auditor_client = APIClient() + self.auditor_client.force_authenticate(user=self.auditor_user) + + # Assign auditor role to auditor_user + self.add_user_to_role_in_course( + self.auditor_user, + COURSE_AUDITOR.external_key, + self.course_key + ) + + def _update_tags_request(self, object_id, tags_data=None): + """Helper method to make PUT request to update tags.""" + if tags_data is None: + tags_data = [ + { + "taxonomy": self.taxonomy.pk, + "tags": ["Tag 1", "Tag 2"] + } + ] + + url = OBJECT_TAG_UPDATE_URL.format(object_id=object_id) + return url, {"tagsData": tags_data} + + def _get_tags_request(self, object_id): + """Helper method to make GET request to retrieve tags.""" + url = OBJECT_TAGS_URL.format(object_id=object_id) + return url + + def test_course_staff_can_update_tags(self): + """course_staff can update tags → 200""" + url, data = self._update_tags_request(str(self.course_key)) + response = self.authorized_client.put(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + + def test_course_auditor_denied_update(self): + """course_auditor denied → 403""" + url, data = self._update_tags_request(str(self.course_key)) + response = self.auditor_client.put(url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_no_role_user_denied_update(self): + """No-role user denied → 403""" + url, data = self._update_tags_request(str(self.course_key)) + response = self.unauthorized_client.put(url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_superuser_allowed_update(self): + """Superuser allowed → 200""" + url, data = self._update_tags_request(str(self.course_key)) + response = self.super_client.put(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + + def test_django_staff_allowed_update(self): + """Django is_staff allowed → 200""" + url, data = self._update_tags_request(str(self.course_key)) + response = self.staff_client.put(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + + def test_cross_course_scoping_denied(self): + """course_staff for course A tags course B → 403""" + url, data = self._update_tags_request(str(self.other_course_key)) + response = self.authorized_client.put(url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_course_staff_sees_manage_permissions(self): + """course_staff sees can_tag_object=True, can_delete_objecttag=True""" + # First add some tags to the course + url, data = self._update_tags_request(str(self.course_key)) + response = self.authorized_client.put(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + + # Now check permissions in GET response + url = self._get_tags_request(str(self.course_key)) + response = self.authorized_client.get(url) + assert response.status_code == status.HTTP_200_OK + + # Check serializer permissions in response data + taxonomies = response.data[str(self.course_key)]["taxonomies"] + assert len(taxonomies) == 1 + for taxonomy_data in taxonomies: + assert taxonomy_data.get('can_tag_object') is True + tags = taxonomy_data.get('tags', []) + assert len(tags) == 2 + for tag_data in tags: + assert tag_data.get('can_delete_objecttag') is True + + def test_course_auditor_sees_view_only_permissions(self): + """course_auditor sees can_tag_object=False, can_delete_objecttag=False""" + # First add some tags using authorized user + url, data = self._update_tags_request(str(self.course_key)) + response = self.authorized_client.put(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + + # Now check permissions as auditor in GET response + url = self._get_tags_request(str(self.course_key)) + response = self.auditor_client.get(url) + assert response.status_code == status.HTTP_200_OK + + # Check serializer permissions in response data + taxonomies = response.data[str(self.course_key)]["taxonomies"] + assert len(taxonomies) == 1 + for taxonomy_data in taxonomies: + assert taxonomy_data.get('can_tag_object') is False + tags = taxonomy_data.get('tags', []) + assert len(tags) == 2 + for tag_data in tags: + assert tag_data.get('can_delete_objecttag') is False + + def test_no_role_user_denied_view(self): + """No-role user denied on view → 403 (checks view_course)""" + url = self._get_tags_request(str(self.course_key)) + response = self.unauthorized_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_library_fallthrough_to_legacy(self): + """Library object_id falls through to legacy permissions""" + # Create organization for library + org, created = Organization.objects.get_or_create(short_name="TestOrg") + + # Create library + library = create_library( + org=org, + slug="test-lib", + title="Test Library", + description="Test library for authz fallthrough", + ) + library_key = library.key + + # Grant library access to authorized_user + set_library_user_permissions( + library_key, + self.authorized_user, + AccessLevel.ADMIN_LEVEL + ) + + # Test that library requests fall through to legacy permissions + url = self._get_tags_request(str(library_key)) + response = self.authorized_client.get(url) + # Should succeed via legacy permissions, not authz + assert response.status_code == status.HTTP_200_OK + + @skip_unless_cms @ddt.ddt class TestDownloadTemplateView(APITestCase): diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py index a52a8810b547..e7175327ee47 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -9,9 +9,12 @@ from openedx_events.content_authoring.signals import CONTENT_OBJECT_ASSOCIATIONS_CHANGED, CONTENT_OBJECT_TAGS_CHANGED from openedx_tagging import rules as oel_tagging_rules from openedx_tagging.rest_api.v1.views import ObjectTagView, TaxonomyView +from openedx_authz.constants.permissions import COURSES_MANAGE_TAGS, COURSES_VIEW_COURSE +from openedx_authz import api as authz_api from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -26,11 +29,12 @@ get_unassigned_taxonomies, set_taxonomy_orgs, ) -from ...auth import has_view_object_tags_access +from ...auth import has_view_object_tags_access, should_use_authz_for_object from ...rules import get_admin_orgs from .filters import ObjectTagTaxonomyOrgFilterBackend, UserOrgFilterBackend from .serializers import ( ObjectTagCopiedMinimalSerializer, + ObjectTagOrgByTaxonomySerializer, TaxonomyOrgListQueryParamsSerializer, TaxonomyOrgSerializer, TaxonomyUpdateOrgBodySerializer, @@ -151,9 +155,68 @@ class ObjectTagOrgView(ObjectTagView): Refer to ObjectTagView docstring for usage details. """ + # Serializer overrides minimal_serializer_class = ObjectTagCopiedMinimalSerializer + object_tags_serializer_class = ObjectTagOrgByTaxonomySerializer + filter_backends = [ObjectTagTaxonomyOrgFilterBackend] + def _should_use_authz(self) -> bool: + """ + Determine if we should use openedx-authz for the current object_id. + """ + object_id = self.kwargs.get('object_id') + if object_id: + should_use_authz, _ = should_use_authz_for_object(object_id) + return should_use_authz + return False + + def get_permissions(self): + """ + Override get_permissions when using openedx-authz. + + When the toggle is enabled for course objects, we need to change the default + permission classes set by the parent ObjectTagView so that only openedx-authz + permissions are used. + """ + if self._should_use_authz(): + return [IsAuthenticated()] + + return super().get_permissions() + + def ensure_has_view_object_tag_permission(self, user, taxonomy, object_id): + """ + Check if user has permission to view object tags. + + This method is overridden to conditionally use openedx-authz when the toggle is enabled. + """ + should_use_authz, course_key = should_use_authz_for_object(object_id) + if should_use_authz and not authz_api.is_user_allowed( + user.username, COURSES_VIEW_COURSE.identifier, str(course_key) + ): + raise PermissionDenied("You do not have permission to view object tags.") + elif not should_use_authz: + # Fall back to parent implementation + super().ensure_has_view_object_tag_permission(user, taxonomy, object_id) + + def ensure_user_has_can_tag_object_permissions(self, user, tags_data, object_id): + """ + Check if user has permission to tag object for each taxonomy in tags_data. + + This method is overridden to conditionally use openedx-authz when the toggle is enabled. + + When using openedx-authz, if the user has manage tags permission for the course, + they can tag the object regardless of the taxonomy. + """ + should_use_authz, course_key = should_use_authz_for_object(object_id) + if should_use_authz and not authz_api.is_user_allowed( + user.username, COURSES_MANAGE_TAGS.identifier, str(course_key) + ): + raise PermissionDenied("You do not have permission to manage object tags.") + elif not should_use_authz: + # Fall back to parent implementation + super().ensure_user_has_can_tag_object_permissions(user, tags_data, object_id) + def update(self, request, *args, **kwargs) -> Response: """ Extend the update method to fire CONTENT_OBJECT_ASSOCIATIONS_CHANGED event From 20587dd1958b70e39f825940e834572f831fe1bf Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Wed, 22 Apr 2026 17:28:11 -0600 Subject: [PATCH 14/17] temp: point to openedx-core branch for CI --- requirements/edx/base.txt | 23 +++++++++++------------ requirements/edx/development.txt | 30 ++++++++++++++---------------- requirements/edx/doc.txt | 22 ++++++++++------------ requirements/edx/github.in | 3 ++- requirements/edx/testing.txt | 26 ++++++++++++-------------- 5 files changed, 49 insertions(+), 55 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 71790d659ff7..f7a438307ae2 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -68,14 +68,14 @@ bleach[css]==6.3.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto3==1.42.94 +boto3==1.42.93 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 # snowflake-connector-python -botocore==1.42.94 +botocore==1.42.93 # via # -r requirements/edx/kernel.in # boto3 @@ -252,7 +252,7 @@ django==5.2.13 # xss-utils django-appconf==1.2.0 # via django-statici18n -django-autocomplete-light==4.0.0 +django-autocomplete-light==3.12.1 # via -r requirements/edx/kernel.in django-cache-memoize==0.2.1 # via edx-enterprise @@ -280,7 +280,6 @@ django-crum==0.7.9 # edx-proctoring # edx-rbac # edx-toggles - # openedx-authz # super-csv django-fernet-fields-v2==0.9 # via @@ -734,7 +733,7 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lti-consumer-xblock==11.0.1 +lti-consumer-xblock==11.0.0 # via -r requirements/edx/kernel.in lxml[html-clean]==5.3.2 # via @@ -834,15 +833,16 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.14.0 +openedx-authz==1.12.0 # via -r requirements/edx/kernel.in openedx-calc==5.0.0 # via # -r requirements/edx/kernel.in # xblocks-contrib -openedx-core==0.44.0 +openedx-core @ git+https://github.com/WGU-Open-edX/openedx-core.git@tpayne/object-tag-modular-permissions # via # -c requirements/constraints.txt + # -r requirements/edx/github.in # -r requirements/edx/kernel.in openedx-django-pyfs==4.0.0 # via xblock @@ -850,7 +850,7 @@ openedx-django-require==3.0.0 # via -r requirements/edx/kernel.in openedx-django-wiki==3.1.1 # via -r requirements/edx/kernel.in -openedx-events==11.2.0 +openedx-events==11.1.1 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -858,7 +858,6 @@ openedx-events==11.2.0 # edx-event-bus-redis # event-tracking # lti-consumer-xblock - # openedx-authz # openedx-core # ora2 openedx-filters==3.1.0 @@ -1110,7 +1109,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-core -s3transfer==0.16.1 +s3transfer==0.16.0 # via boto3 sailthru-client==2.2.3 # via edx-ace @@ -1124,7 +1123,7 @@ shapely==2.1.2 # xblocks-contrib simpleeval==1.0.7 # via pycasbin -simplejson==4.1.0 +simplejson==4.0.1 # via # -r requirements/edx/kernel.in # sailthru-client @@ -1165,7 +1164,7 @@ social-auth-app-django==5.4.1 # -r requirements/edx/kernel.in # edx-auth-backends # edx-enterprise -social-auth-core==4.8.7 +social-auth-core==4.8.6 # via # -r requirements/edx/kernel.in # edx-auth-backends diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 3ad4c94197fd..236c30476122 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -139,7 +139,7 @@ bleach[css]==6.3.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto3==1.42.94 +boto3==1.42.93 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -147,7 +147,7 @@ boto3==1.42.94 # fs-s3fs # ora2 # snowflake-connector-python -botocore==1.42.94 +botocore==1.42.93 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -163,7 +163,7 @@ bridgekeeper==0.9 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -build==1.4.4 +build==1.4.3 # via # -r requirements/pip-tools.txt # pip-tools @@ -429,7 +429,7 @@ django-appconf==1.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-statici18n -django-autocomplete-light==4.0.0 +django-autocomplete-light==3.12.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -472,7 +472,6 @@ django-crum==0.7.9 # edx-proctoring # edx-rbac # edx-toggles - # openedx-authz # super-csv django-debug-toolbar==5.2.0 # via @@ -916,7 +915,7 @@ faker==40.15.0 # via # -r requirements/edx/testing.txt # factory-boy -fastapi==0.136.1 +fastapi==0.136.0 # via # -r requirements/edx/testing.txt # pact-python @@ -1214,7 +1213,7 @@ libsass==0.10.0 # via # -c requirements/constraints.txt # -r requirements/edx/assets.txt -lti-consumer-xblock==11.0.1 +lti-consumer-xblock==11.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1385,7 +1384,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.14.0 +openedx-authz==1.12.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1394,7 +1393,7 @@ openedx-calc==5.0.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # xblocks-contrib -openedx-core==0.44.0 +openedx-core @ git+https://github.com/WGU-Open-edX/openedx-core.git@tpayne/object-tag-modular-permissions # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt @@ -1412,7 +1411,7 @@ openedx-django-wiki==3.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==11.2.0 +openedx-events==11.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1421,7 +1420,6 @@ openedx-events==11.2.0 # edx-event-bus-redis # event-tracking # lti-consumer-xblock - # openedx-authz # openedx-core # ora2 openedx-filters==3.1.0 @@ -1482,7 +1480,7 @@ path-py==12.5.0 # edx-enterprise # ora2 # staff-graded-xblock -pathspec==1.1.0 +pathspec==1.0.4 # via mypy pgpy==0.6.0 # via @@ -1919,7 +1917,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-core -s3transfer==0.16.1 +s3transfer==0.16.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1949,7 +1947,7 @@ simpleeval==1.0.7 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pycasbin -simplejson==4.1.0 +simplejson==4.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2010,7 +2008,7 @@ social-auth-app-django==5.4.1 # -r requirements/edx/testing.txt # edx-auth-backends # edx-enterprise -social-auth-core==4.8.7 +social-auth-core==4.8.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2245,7 +2243,7 @@ urllib3==2.6.3 # pact-python # requests # types-requests -uvicorn==0.46.0 +uvicorn==0.45.0 # via # -r requirements/edx/testing.txt # pact-python diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index b7f29a68f1df..ac397e62fedd 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -105,14 +105,14 @@ bleach[css]==6.3.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto3==1.42.94 +boto3==1.42.93 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 # snowflake-connector-python -botocore==1.42.94 +botocore==1.42.93 # via # -r requirements/edx/base.txt # boto3 @@ -318,7 +318,7 @@ django-appconf==1.2.0 # via # -r requirements/edx/base.txt # django-statici18n -django-autocomplete-light==4.0.0 +django-autocomplete-light==3.12.1 # via -r requirements/edx/base.txt django-cache-memoize==0.2.1 # via @@ -350,7 +350,6 @@ django-crum==0.7.9 # edx-proctoring # edx-rbac # edx-toggles - # openedx-authz # super-csv django-fernet-fields-v2==0.9 # via @@ -893,7 +892,7 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lti-consumer-xblock==11.0.1 +lti-consumer-xblock==11.0.0 # via -r requirements/edx/base.txt lxml[html-clean]==5.3.2 # via @@ -1012,13 +1011,13 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.14.0 +openedx-authz==1.12.0 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core==0.44.0 +openedx-core @ git+https://github.com/WGU-Open-edX/openedx-core.git@tpayne/object-tag-modular-permissions # via # -c requirements/constraints.txt # -r requirements/edx/base.txt @@ -1030,7 +1029,7 @@ openedx-django-require==3.0.0 # via -r requirements/edx/base.txt openedx-django-wiki==3.1.1 # via -r requirements/edx/base.txt -openedx-events==11.2.0 +openedx-events==11.1.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1038,7 +1037,6 @@ openedx-events==11.2.0 # edx-event-bus-redis # event-tracking # lti-consumer-xblock - # openedx-authz # openedx-core # ora2 openedx-filters==3.1.0 @@ -1350,7 +1348,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-core -s3transfer==0.16.1 +s3transfer==0.16.0 # via # -r requirements/edx/base.txt # boto3 @@ -1374,7 +1372,7 @@ simpleeval==1.0.7 # via # -r requirements/edx/base.txt # pycasbin -simplejson==4.1.0 +simplejson==4.0.1 # via # -r requirements/edx/base.txt # sailthru-client @@ -1420,7 +1418,7 @@ social-auth-app-django==5.4.1 # -r requirements/edx/base.txt # edx-auth-backends # edx-enterprise -social-auth-core==4.8.7 +social-auth-core==4.8.6 # via # -r requirements/edx/base.txt # edx-auth-backends diff --git a/requirements/edx/github.in b/requirements/edx/github.in index 7fdb2c051ce8..be75e65fb185 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -80,7 +80,8 @@ # Release candidates being tested. ############################################################################## -# ... add dependencies here +# Temporary to test in CI for PR +git+https://github.com/WGU-Open-edX/openedx-core.git@tpayne/object-tag-modular-permissions#egg=openedx-core ############################################################################## # Critical fixes for packages that are not yet available in a PyPI release. diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 1e14484defc2..63c74d03d91a 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -103,14 +103,14 @@ bleach[css]==6.3.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto3==1.42.94 +boto3==1.42.93 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 # snowflake-connector-python -botocore==1.42.94 +botocore==1.42.93 # via # -r requirements/edx/base.txt # boto3 @@ -343,7 +343,7 @@ django-appconf==1.2.0 # via # -r requirements/edx/base.txt # django-statici18n -django-autocomplete-light==4.0.0 +django-autocomplete-light==3.12.1 # via -r requirements/edx/base.txt django-cache-memoize==0.2.1 # via @@ -375,7 +375,6 @@ django-crum==0.7.9 # edx-proctoring # edx-rbac # edx-toggles - # openedx-authz # super-csv django-fernet-fields-v2==0.9 # via @@ -713,7 +712,7 @@ factory-boy==3.3.3 # via -r requirements/edx/testing.in faker==40.15.0 # via factory-boy -fastapi==0.136.1 +fastapi==0.136.0 # via pact-python fastavro==1.12.1 # via @@ -932,7 +931,7 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lti-consumer-xblock==11.0.1 +lti-consumer-xblock==11.0.0 # via -r requirements/edx/base.txt lxml[html-clean]==5.3.2 # via @@ -1059,13 +1058,13 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.14.0 +openedx-authz==1.12.0 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via # -r requirements/edx/base.txt # xblocks-contrib -openedx-core==0.44.0 +openedx-core @ git+https://github.com/WGU-Open-edX/openedx-core.git@tpayne/object-tag-modular-permissions # via # -c requirements/constraints.txt # -r requirements/edx/base.txt @@ -1077,7 +1076,7 @@ openedx-django-require==3.0.0 # via -r requirements/edx/base.txt openedx-django-wiki==3.1.1 # via -r requirements/edx/base.txt -openedx-events==11.2.0 +openedx-events==11.1.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1085,7 +1084,6 @@ openedx-events==11.2.0 # edx-event-bus-redis # event-tracking # lti-consumer-xblock - # openedx-authz # openedx-core # ora2 openedx-filters==3.1.0 @@ -1471,7 +1469,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-core -s3transfer==0.16.1 +s3transfer==0.16.0 # via # -r requirements/edx/base.txt # boto3 @@ -1495,7 +1493,7 @@ simpleeval==1.0.7 # via # -r requirements/edx/base.txt # pycasbin -simplejson==4.1.0 +simplejson==4.0.1 # via # -r requirements/edx/base.txt # sailthru-client @@ -1541,7 +1539,7 @@ social-auth-app-django==5.4.1 # -r requirements/edx/base.txt # edx-auth-backends # edx-enterprise -social-auth-core==4.8.7 +social-auth-core==4.8.6 # via # -r requirements/edx/base.txt # edx-auth-backends @@ -1676,7 +1674,7 @@ urllib3==2.6.3 # elasticsearch # pact-python # requests -uvicorn==0.46.0 +uvicorn==0.45.0 # via pact-python vine==5.1.0 # via From 67065ed374eea99a3f5fa3dc00ea3d09a6358a78 Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Wed, 22 Apr 2026 17:55:21 -0600 Subject: [PATCH 15/17] fixup! feat: check authz permissions for course tagging --- cms/djangoapps/contentstore/views/block.py | 6 ++-- .../contentstore/views/tests/test_block.py | 29 +++++++++---------- .../core/djangoapps/content_tagging/auth.py | 7 +++-- .../rest_api/v1/serializers.py | 9 +++--- .../rest_api/v1/tests/test_views.py | 28 +++++++++--------- .../content_tagging/rest_api/v1/views.py | 8 ++--- 6 files changed, 42 insertions(+), 45 deletions(-) diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index 8aa821d98108..cb362dc98db0 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -11,8 +11,8 @@ from django.utils.translation import gettext as _ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.http import require_http_methods -from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from web_fragments.fragment import Fragment from cms.djangoapps.contentstore.utils import load_services_for_studio @@ -28,9 +28,9 @@ from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access from common.djangoapps.util.json_request import JsonResponse, expect_json -from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled -from openedx.core.djangoapps.authz.decorators import user_has_course_permission from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled from openedx.core.lib.xblock_utils import hash_resource, request_token, wrap_xblock, wrap_xblock_aside from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.x_module import ( # lint-amnesty, pylint: disable=wrong-import-order diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index cbd1cc82b11d..91921026a776 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -9,7 +9,6 @@ import ddt from bs4 import BeautifulSoup from django.conf import settings -from django.core.exceptions import PermissionDenied from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory @@ -20,6 +19,7 @@ from opaque_keys.edx.asides import AsideUsageKeyV2 from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from openedx_authz.constants.roles import COURSE_STAFF from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED from openedx_events.testing import OpenEdxEventsTestMixin @@ -55,13 +55,10 @@ from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from common.test.utils import assert_dict_contains_subset from lms.djangoapps.lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION -from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE -from openedx_authz.constants.permissions import COURSES_VIEW_COURSE -from openedx_authz.constants.roles import COURSE_STAFF from xmodule.course_block import DEFAULT_START_DATE from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -3529,10 +3526,10 @@ def test_authorized_user_gets_json_response(self): Test that authorized user gets JSON response from xblock_outline_handler. """ outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) - + self.client.login(username=self.authorized_user.username, password=self.password) resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") - + assert resp.status_code == 200 json_response = json.loads(resp.content.decode("utf-8")) assert "id" in json_response @@ -3544,10 +3541,10 @@ def test_unauthorized_user_gets_permission_denied(self): Test that unauthorized user gets 403 response from xblock_outline_handler. """ outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) - + self.client.login(username=self.unauthorized_user.username, password=self.password) resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") - + assert resp.status_code == 403 def test_superuser_gets_json_response(self): @@ -3555,10 +3552,10 @@ def test_superuser_gets_json_response(self): Test that superuser gets JSON response from xblock_outline_handler. """ outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) - + self.client.login(username=self.super_user.username, password=self.password) resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") - + assert resp.status_code == 200 json_response = json.loads(resp.content.decode("utf-8")) assert "id" in json_response @@ -3570,10 +3567,10 @@ def test_staff_user_gets_json_response(self): Test that staff user gets JSON response from xblock_outline_handler. """ outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) - + self.client.login(username=self.staff_user.username, password=self.password) resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") - + assert resp.status_code == 200 json_response = json.loads(resp.content.decode("utf-8")) assert "id" in json_response @@ -3585,10 +3582,10 @@ def test_authorized_chapter_outline(self): Test that authorized user can access chapter-level outline. """ outline_url = reverse_usage_url("xblock_outline_handler", self.chapter.location) - + self.client.login(username=self.authorized_user.username, password=self.password) resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") - + assert resp.status_code == 200 json_response = json.loads(resp.content.decode("utf-8")) assert json_response["display_name"] == "Week 1" @@ -3603,10 +3600,10 @@ def test_unauthorized_chapter_outline(self): Test that unauthorized user cannot access chapter-level outline. """ outline_url = reverse_usage_url("xblock_outline_handler", self.chapter.location) - + self.client.login(username=self.unauthorized_user.username, password=self.password) resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") - + assert resp.status_code == 403 diff --git a/openedx/core/djangoapps/content_tagging/auth.py b/openedx/core/djangoapps/content_tagging/auth.py index 1458f0e81348..a467057fd08d 100644 --- a/openedx/core/djangoapps/content_tagging/auth.py +++ b/openedx/core/djangoapps/content_tagging/auth.py @@ -7,10 +7,11 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_authz import api as authz_api -from openedx_authz.constants.permissions import COURSES_EXPORT_TAGS, COURSES_MANAGE_TAGS, COURSES_VIEW_COURSE +from openedx_authz.constants.permissions import COURSES_EXPORT_TAGS from openedx_tagging import rules as oel_tagging_rules from openedx.core import toggles as core_toggles + from .utils import get_context_key_from_key_string log = logging.getLogger(__name__) @@ -46,7 +47,7 @@ def has_view_object_tags_access(user, object_id): def should_use_authz_for_object(object_id) -> tuple[bool, CourseKey | None]: """ Check if openedx-authz should be used for the given object based on the context key and toggle. - + Returns (should_use_authz, course_key) where: - should_use_authz: True if authz should be used, False otherwise - course_key: The CourseKey if object is a course, None otherwise @@ -62,6 +63,6 @@ def should_use_authz_for_object(object_id) -> tuple[bool, CourseKey | None]: # Check if toggle is active if not core_toggles.enable_authz_course_authoring(context_key): return False, context_key - + # Authz should be used for this course object return True, context_key diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py index cf9bae0b7ba3..58a2a8ea5e6e 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py @@ -4,17 +4,16 @@ from __future__ import annotations +from openedx_authz import api as authz_api +from openedx_authz.constants.permissions import COURSES_MANAGE_TAGS from openedx_tagging.rest_api.v1.serializers import ( ObjectTagMinimalSerializer, ObjectTagsByTaxonomySerializer, - ObjectTagSerializer, TaxonomyListQueryParamsSerializer, TaxonomySerializer, ) from organizations.models import Organization from rest_framework import fields, serializers -from openedx_authz import api as authz_api -from openedx_authz.constants.permissions import COURSES_MANAGE_TAGS from ...auth import should_use_authz_for_object from ...models import TaxonomyOrg @@ -118,7 +117,7 @@ def can_tag_object(self, obj_tag) -> bool | None: request.user.username, COURSES_MANAGE_TAGS.identifier, str(course_key) ) return False - + # Fall back to parent implementation return super().can_tag_object(obj_tag) @@ -139,7 +138,7 @@ class Meta(ObjectTagMinimalSerializer.Meta): def can_delete_object_tag(self, instance) -> bool | None: """ Check if the user is authorized to delete the provided tag. - + Override to return `False` if the object tag is copied, and conditionally use openedx-authz for course objects with the toggle enabled. """ diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index 1b7c2708cd53..556ae04d9634 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -2153,11 +2153,11 @@ def setUpClass(cls): def setUp(self): super().setUp() - + # Create another course for cross-course scoping tests self.other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.authorized_user.id) self.other_course_key = self.other_course.id - + # Create taxonomy self.taxonomy = tagging_api.create_taxonomy( name="Test Taxonomy", @@ -2168,7 +2168,7 @@ def setUp(self): org=None, # Global taxonomy not tied to any org rel_type=TaxonomyOrg.RelType.OWNER, ) - + # Create tags self.tag1 = Tag.objects.create( taxonomy=self.taxonomy, @@ -2178,12 +2178,12 @@ def setUp(self): taxonomy=self.taxonomy, value="Tag 2", ) - - # Create auditor user with view-only permissions + + # Create auditor user with view-only permissions self.auditor_user = UserFactory(password=self.password) self.auditor_client = APIClient() self.auditor_client.force_authenticate(user=self.auditor_user) - + # Assign auditor role to auditor_user self.add_user_to_role_in_course( self.auditor_user, @@ -2200,7 +2200,7 @@ def _update_tags_request(self, object_id, tags_data=None): "tags": ["Tag 1", "Tag 2"] } ] - + url = OBJECT_TAG_UPDATE_URL.format(object_id=object_id) return url, {"tagsData": tags_data} @@ -2251,12 +2251,12 @@ def test_course_staff_sees_manage_permissions(self): url, data = self._update_tags_request(str(self.course_key)) response = self.authorized_client.put(url, data, format='json') assert response.status_code == status.HTTP_200_OK - + # Now check permissions in GET response url = self._get_tags_request(str(self.course_key)) response = self.authorized_client.get(url) assert response.status_code == status.HTTP_200_OK - + # Check serializer permissions in response data taxonomies = response.data[str(self.course_key)]["taxonomies"] assert len(taxonomies) == 1 @@ -2273,12 +2273,12 @@ def test_course_auditor_sees_view_only_permissions(self): url, data = self._update_tags_request(str(self.course_key)) response = self.authorized_client.put(url, data, format='json') assert response.status_code == status.HTTP_200_OK - + # Now check permissions as auditor in GET response url = self._get_tags_request(str(self.course_key)) response = self.auditor_client.get(url) assert response.status_code == status.HTTP_200_OK - + # Check serializer permissions in response data taxonomies = response.data[str(self.course_key)]["taxonomies"] assert len(taxonomies) == 1 @@ -2299,7 +2299,7 @@ def test_library_fallthrough_to_legacy(self): """Library object_id falls through to legacy permissions""" # Create organization for library org, created = Organization.objects.get_or_create(short_name="TestOrg") - + # Create library library = create_library( org=org, @@ -2308,14 +2308,14 @@ def test_library_fallthrough_to_legacy(self): description="Test library for authz fallthrough", ) library_key = library.key - + # Grant library access to authorized_user set_library_user_permissions( library_key, self.authorized_user, AccessLevel.ADMIN_LEVEL ) - + # Test that library requests fall through to legacy permissions url = self._get_tags_request(str(library_key)) response = self.authorized_client.get(url) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py index e7175327ee47..fefbe90b2b96 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -5,12 +5,12 @@ from django.db.models import Count from django.http import StreamingHttpResponse +from openedx_authz import api as authz_api +from openedx_authz.constants.permissions import COURSES_MANAGE_TAGS, COURSES_VIEW_COURSE from openedx_events.content_authoring.data import ContentObjectChangedData, ContentObjectData from openedx_events.content_authoring.signals import CONTENT_OBJECT_ASSOCIATIONS_CHANGED, CONTENT_OBJECT_TAGS_CHANGED from openedx_tagging import rules as oel_tagging_rules from openedx_tagging.rest_api.v1.views import ObjectTagView, TaxonomyView -from openedx_authz.constants.permissions import COURSES_MANAGE_TAGS, COURSES_VIEW_COURSE -from openedx_authz import api as authz_api from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError @@ -174,14 +174,14 @@ def _should_use_authz(self) -> bool: def get_permissions(self): """ Override get_permissions when using openedx-authz. - + When the toggle is enabled for course objects, we need to change the default permission classes set by the parent ObjectTagView so that only openedx-authz permissions are used. """ if self._should_use_authz(): return [IsAuthenticated()] - + return super().get_permissions() def ensure_has_view_object_tag_permission(self, user, taxonomy, object_id): From 988e3e6e6e77c8e7f6516ff64b9db8c15576677b Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Thu, 23 Apr 2026 11:09:55 -0600 Subject: [PATCH 16/17] fixup! temp: point to openedx-core branch for CI --- requirements/edx/base.txt | 20 +++++++++++--------- requirements/edx/development.txt | 28 +++++++++++++++------------- requirements/edx/doc.txt | 20 +++++++++++--------- requirements/edx/testing.txt | 24 +++++++++++++----------- 4 files changed, 50 insertions(+), 42 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index f7a438307ae2..14e914cbc402 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -68,14 +68,14 @@ bleach[css]==6.3.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto3==1.42.93 +boto3==1.42.94 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 # snowflake-connector-python -botocore==1.42.93 +botocore==1.42.94 # via # -r requirements/edx/kernel.in # boto3 @@ -252,7 +252,7 @@ django==5.2.13 # xss-utils django-appconf==1.2.0 # via django-statici18n -django-autocomplete-light==3.12.1 +django-autocomplete-light==4.0.0 # via -r requirements/edx/kernel.in django-cache-memoize==0.2.1 # via edx-enterprise @@ -280,6 +280,7 @@ django-crum==0.7.9 # edx-proctoring # edx-rbac # edx-toggles + # openedx-authz # super-csv django-fernet-fields-v2==0.9 # via @@ -733,7 +734,7 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lti-consumer-xblock==11.0.0 +lti-consumer-xblock==11.0.1 # via -r requirements/edx/kernel.in lxml[html-clean]==5.3.2 # via @@ -833,7 +834,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.12.0 +openedx-authz==1.14.0 # via -r requirements/edx/kernel.in openedx-calc==5.0.0 # via @@ -850,7 +851,7 @@ openedx-django-require==3.0.0 # via -r requirements/edx/kernel.in openedx-django-wiki==3.1.1 # via -r requirements/edx/kernel.in -openedx-events==11.1.1 +openedx-events==11.2.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -858,6 +859,7 @@ openedx-events==11.1.1 # edx-event-bus-redis # event-tracking # lti-consumer-xblock + # openedx-authz # openedx-core # ora2 openedx-filters==3.1.0 @@ -1109,7 +1111,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-core -s3transfer==0.16.0 +s3transfer==0.16.1 # via boto3 sailthru-client==2.2.3 # via edx-ace @@ -1123,7 +1125,7 @@ shapely==2.1.2 # xblocks-contrib simpleeval==1.0.7 # via pycasbin -simplejson==4.0.1 +simplejson==4.1.0 # via # -r requirements/edx/kernel.in # sailthru-client @@ -1164,7 +1166,7 @@ social-auth-app-django==5.4.1 # -r requirements/edx/kernel.in # edx-auth-backends # edx-enterprise -social-auth-core==4.8.6 +social-auth-core==4.8.7 # via # -r requirements/edx/kernel.in # edx-auth-backends diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 236c30476122..5fa50ee944bc 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -139,7 +139,7 @@ bleach[css]==6.3.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto3==1.42.93 +boto3==1.42.94 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -147,7 +147,7 @@ boto3==1.42.93 # fs-s3fs # ora2 # snowflake-connector-python -botocore==1.42.93 +botocore==1.42.94 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -163,7 +163,7 @@ bridgekeeper==0.9 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -build==1.4.3 +build==1.4.4 # via # -r requirements/pip-tools.txt # pip-tools @@ -429,7 +429,7 @@ django-appconf==1.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-statici18n -django-autocomplete-light==3.12.1 +django-autocomplete-light==4.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -472,6 +472,7 @@ django-crum==0.7.9 # edx-proctoring # edx-rbac # edx-toggles + # openedx-authz # super-csv django-debug-toolbar==5.2.0 # via @@ -915,7 +916,7 @@ faker==40.15.0 # via # -r requirements/edx/testing.txt # factory-boy -fastapi==0.136.0 +fastapi==0.136.1 # via # -r requirements/edx/testing.txt # pact-python @@ -1213,7 +1214,7 @@ libsass==0.10.0 # via # -c requirements/constraints.txt # -r requirements/edx/assets.txt -lti-consumer-xblock==11.0.0 +lti-consumer-xblock==11.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1384,7 +1385,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.12.0 +openedx-authz==1.14.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1411,7 +1412,7 @@ openedx-django-wiki==3.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==11.1.1 +openedx-events==11.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1420,6 +1421,7 @@ openedx-events==11.1.1 # edx-event-bus-redis # event-tracking # lti-consumer-xblock + # openedx-authz # openedx-core # ora2 openedx-filters==3.1.0 @@ -1480,7 +1482,7 @@ path-py==12.5.0 # edx-enterprise # ora2 # staff-graded-xblock -pathspec==1.0.4 +pathspec==1.1.0 # via mypy pgpy==0.6.0 # via @@ -1917,7 +1919,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-core -s3transfer==0.16.0 +s3transfer==0.16.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1947,7 +1949,7 @@ simpleeval==1.0.7 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pycasbin -simplejson==4.0.1 +simplejson==4.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2008,7 +2010,7 @@ social-auth-app-django==5.4.1 # -r requirements/edx/testing.txt # edx-auth-backends # edx-enterprise -social-auth-core==4.8.6 +social-auth-core==4.8.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2243,7 +2245,7 @@ urllib3==2.6.3 # pact-python # requests # types-requests -uvicorn==0.45.0 +uvicorn==0.46.0 # via # -r requirements/edx/testing.txt # pact-python diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index ac397e62fedd..bb566b9513d5 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -105,14 +105,14 @@ bleach[css]==6.3.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto3==1.42.93 +boto3==1.42.94 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 # snowflake-connector-python -botocore==1.42.93 +botocore==1.42.94 # via # -r requirements/edx/base.txt # boto3 @@ -318,7 +318,7 @@ django-appconf==1.2.0 # via # -r requirements/edx/base.txt # django-statici18n -django-autocomplete-light==3.12.1 +django-autocomplete-light==4.0.0 # via -r requirements/edx/base.txt django-cache-memoize==0.2.1 # via @@ -350,6 +350,7 @@ django-crum==0.7.9 # edx-proctoring # edx-rbac # edx-toggles + # openedx-authz # super-csv django-fernet-fields-v2==0.9 # via @@ -892,7 +893,7 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lti-consumer-xblock==11.0.0 +lti-consumer-xblock==11.0.1 # via -r requirements/edx/base.txt lxml[html-clean]==5.3.2 # via @@ -1011,7 +1012,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.12.0 +openedx-authz==1.14.0 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via @@ -1029,7 +1030,7 @@ openedx-django-require==3.0.0 # via -r requirements/edx/base.txt openedx-django-wiki==3.1.1 # via -r requirements/edx/base.txt -openedx-events==11.1.1 +openedx-events==11.2.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1037,6 +1038,7 @@ openedx-events==11.1.1 # edx-event-bus-redis # event-tracking # lti-consumer-xblock + # openedx-authz # openedx-core # ora2 openedx-filters==3.1.0 @@ -1348,7 +1350,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-core -s3transfer==0.16.0 +s3transfer==0.16.1 # via # -r requirements/edx/base.txt # boto3 @@ -1372,7 +1374,7 @@ simpleeval==1.0.7 # via # -r requirements/edx/base.txt # pycasbin -simplejson==4.0.1 +simplejson==4.1.0 # via # -r requirements/edx/base.txt # sailthru-client @@ -1418,7 +1420,7 @@ social-auth-app-django==5.4.1 # -r requirements/edx/base.txt # edx-auth-backends # edx-enterprise -social-auth-core==4.8.6 +social-auth-core==4.8.7 # via # -r requirements/edx/base.txt # edx-auth-backends diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 63c74d03d91a..ab364a419c2e 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -103,14 +103,14 @@ bleach[css]==6.3.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto3==1.42.93 +boto3==1.42.94 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 # snowflake-connector-python -botocore==1.42.93 +botocore==1.42.94 # via # -r requirements/edx/base.txt # boto3 @@ -343,7 +343,7 @@ django-appconf==1.2.0 # via # -r requirements/edx/base.txt # django-statici18n -django-autocomplete-light==3.12.1 +django-autocomplete-light==4.0.0 # via -r requirements/edx/base.txt django-cache-memoize==0.2.1 # via @@ -375,6 +375,7 @@ django-crum==0.7.9 # edx-proctoring # edx-rbac # edx-toggles + # openedx-authz # super-csv django-fernet-fields-v2==0.9 # via @@ -712,7 +713,7 @@ factory-boy==3.3.3 # via -r requirements/edx/testing.in faker==40.15.0 # via factory-boy -fastapi==0.136.0 +fastapi==0.136.1 # via pact-python fastavro==1.12.1 # via @@ -931,7 +932,7 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lti-consumer-xblock==11.0.0 +lti-consumer-xblock==11.0.1 # via -r requirements/edx/base.txt lxml[html-clean]==5.3.2 # via @@ -1058,7 +1059,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.12.0 +openedx-authz==1.14.0 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via @@ -1076,7 +1077,7 @@ openedx-django-require==3.0.0 # via -r requirements/edx/base.txt openedx-django-wiki==3.1.1 # via -r requirements/edx/base.txt -openedx-events==11.1.1 +openedx-events==11.2.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1084,6 +1085,7 @@ openedx-events==11.1.1 # edx-event-bus-redis # event-tracking # lti-consumer-xblock + # openedx-authz # openedx-core # ora2 openedx-filters==3.1.0 @@ -1469,7 +1471,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-core -s3transfer==0.16.0 +s3transfer==0.16.1 # via # -r requirements/edx/base.txt # boto3 @@ -1493,7 +1495,7 @@ simpleeval==1.0.7 # via # -r requirements/edx/base.txt # pycasbin -simplejson==4.0.1 +simplejson==4.1.0 # via # -r requirements/edx/base.txt # sailthru-client @@ -1539,7 +1541,7 @@ social-auth-app-django==5.4.1 # -r requirements/edx/base.txt # edx-auth-backends # edx-enterprise -social-auth-core==4.8.6 +social-auth-core==4.8.7 # via # -r requirements/edx/base.txt # edx-auth-backends @@ -1674,7 +1676,7 @@ urllib3==2.6.3 # elasticsearch # pact-python # requests -uvicorn==0.45.0 +uvicorn==0.46.0 # via pact-python vine==5.1.0 # via From 8269e3845ccc190d1b6636cd205f816170ed1c9b Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Thu, 23 Apr 2026 11:47:32 -0600 Subject: [PATCH 17/17] fixup! feat: check authz permissions for course tagging --- openedx/core/djangoapps/content_tagging/rest_api/v1/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py index fefbe90b2b96..1d4655d3f11b 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -195,7 +195,7 @@ def ensure_has_view_object_tag_permission(self, user, taxonomy, object_id): user.username, COURSES_VIEW_COURSE.identifier, str(course_key) ): raise PermissionDenied("You do not have permission to view object tags.") - elif not should_use_authz: + if not should_use_authz: # Fall back to parent implementation super().ensure_has_view_object_tag_permission(user, taxonomy, object_id) @@ -213,7 +213,7 @@ def ensure_user_has_can_tag_object_permissions(self, user, tags_data, object_id) user.username, COURSES_MANAGE_TAGS.identifier, str(course_key) ): raise PermissionDenied("You do not have permission to manage object tags.") - elif not should_use_authz: + if not should_use_authz: # Fall back to parent implementation super().ensure_user_has_can_tag_object_permissions(user, tags_data, object_id)