From 2e91a0511244e3e3adfac372f1a995d957cf8c83 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 8 May 2025 11:57:07 -0700 Subject: [PATCH] fix: Bugs with "Publish All Changes" in Library [FC-0083] (#36640) * fix: "[created] received a naive datetime" * fix: leaky "isolation" of events was causing test failures * fix: make lib events more specific, emit them async, handle hierarchy correctly * chore: bump openedx-events to 10.2.0 for new library PUBLISHED events --- .../contentstore/views/tests/test_block.py | 6 + openedx/core/djangoapps/content/search/api.py | 23 - .../djangoapps/content/search/handlers.py | 85 ++- .../core/djangoapps/content/search/tasks.py | 3 - .../content/search/tests/test_api.py | 15 - .../content_libraries/api/blocks.py | 30 +- .../content_libraries/api/containers.py | 34 +- .../content_libraries/api/libraries.py | 67 +-- .../djangoapps/content_libraries/tasks.py | 230 +++++++- .../content_libraries/tests/base.py | 76 ++- .../content_libraries/tests/test_api.py | 240 +++----- .../tests/test_containers.py | 154 +---- .../tests/test_content_libraries.py | 324 +---------- .../tests/test_course_to_library.py | 9 +- .../tests/test_embed_block.py | 3 +- .../content_libraries/tests/test_events.py | 548 ++++++++++++++++++ .../tests/test_versioned_apis.py | 3 +- .../course_groups/tests/test_cohorts.py | 6 + .../course_groups/tests/test_events.py | 6 + requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 23 files changed, 1043 insertions(+), 827 deletions(-) create mode 100644 openedx/core/djangoapps/content_libraries/tests/test_events.py diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index f03e21342f..bd0e1c5b12 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -804,6 +804,12 @@ class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin): super().setUpClass() cls.start_events_isolation() + @classmethod + def tearDownClass(cls): + """ Don't let our event isolation affect other test cases """ + super().tearDownClass() + cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. + def setUp(self): """Creates the test course structure and a few components to 'duplicate'.""" super().setUp() diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index b3614e9cc6..b866f13dc4 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -653,29 +653,6 @@ def _delete_index_doc(doc_id) -> None: _wait_for_meili_tasks(tasks) -def delete_all_draft_docs_for_library(library_key: LibraryLocatorV2) -> None: - """ - Deletes draft documents for the given XBlocks from the search index - """ - current_rebuild_index_name = _get_running_rebuild_index_name() - client = _get_meilisearch_client() - # Delete all documents where last_published is null i.e. never published before. - delete_filter = [ - f'{Fields.context_key}="{library_key}"', - # This field should only be NULL or have a value, but we're also checking IS EMPTY just in case. - # Inner arrays are connected by an OR - [f'{Fields.last_published} IS EMPTY', f'{Fields.last_published} IS NULL'], - ] - - tasks = [] - if current_rebuild_index_name: - # If there is a rebuild in progress, the documents will also be deleted from the new index. - tasks.append(client.index(current_rebuild_index_name).delete_documents(filter=delete_filter)) - tasks.append(client.index(STUDIO_INDEX_NAME).delete_documents(filter=delete_filter)) - - _wait_for_meili_tasks(tasks) - - def upsert_library_block_index_doc(usage_key: UsageKey) -> None: """ Creates or updates the document for the given Library Block in the search index diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py index 998b2ef870..315d3cde53 100644 --- a/openedx/core/djangoapps/content/search/handlers.py +++ b/openedx/core/djangoapps/content/search/handlers.py @@ -23,12 +23,14 @@ from openedx_events.content_authoring.signals import ( LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, LIBRARY_COLLECTION_CREATED, LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, LIBRARY_CONTAINER_CREATED, LIBRARY_CONTAINER_DELETED, LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, XBLOCK_CREATED, XBLOCK_DELETED, XBLOCK_UPDATED, @@ -37,6 +39,7 @@ from openedx_events.content_authoring.signals import ( from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.search.models import SearchAccess +from openedx.core.djangoapps.content_libraries import api as lib_api from .api import ( only_if_meilisearch_enabled, @@ -136,6 +139,32 @@ def library_block_updated_handler(**kwargs) -> None: upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) +@receiver(LIBRARY_BLOCK_PUBLISHED) +@only_if_meilisearch_enabled +def library_block_published_handler(**kwargs) -> None: + """ + Update the index for the content library block when its published version + has changed. + """ + library_block_data = kwargs.get("library_block", None) + if not library_block_data or not isinstance(library_block_data, LibraryBlockData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + + # The PUBLISHED event is sent for any change to the published version including deletes, so check if it exists: + try: + lib_api.get_library_block(library_block_data.usage_key) + except lib_api.ContentLibraryBlockNotFound: + log.info(f"Observed published deletion of library block {str(library_block_data.usage_key)}.") + # The document should already have been deleted from the search index + # via the DELETED handler, so there's nothing to do now. + return + + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) + + @receiver(LIBRARY_BLOCK_DELETED) @only_if_meilisearch_enabled def library_block_deleted(**kwargs) -> None: @@ -162,14 +191,14 @@ def content_library_updated_handler(**kwargs) -> None: if not content_library_data or not isinstance(content_library_data, ContentLibraryData): # pragma: no cover log.error("Received null or incorrect data for event") return + library_key = content_library_data.library_key - # Update content library index synchronously to make sure that search index is updated before - # the frontend invalidates/refetches index. - # Currently, this is only required to make sure that removed/discarded components are removed - # from the search index and displayed to user properly. If it becomes a performance bottleneck - # for other update operations other than discard, we can update CONTENT_LIBRARY_UPDATED event - # to include a parameter which can help us decide if the task needs to run sync or async. - update_content_library_index_docs.apply(args=[str(content_library_data.library_key)]) + # For now we assume the library has been renamed. Few other things will trigger this event. + + # Update ALL items in the library, because their breadcrumbs will be outdated. + # TODO: just patch the "breadcrumbs" field? It's the same on every one. + # TODO: check if the library display_name has actually changed before updating all items? + update_content_library_index_docs.apply(args=[str(library_key)]) @receiver(LIBRARY_COLLECTION_CREATED) @@ -248,17 +277,34 @@ def library_container_updated_handler(**kwargs) -> None: log.error("Received null or incorrect data for event") return - if library_container.background: - update_library_container_index_doc.delay( - str(library_container.container_key), - ) - else: - # Update container index synchronously to make sure that search index is updated before - # the frontend invalidates/refetches index. - # See content_library_updated_handler for more details. - update_library_container_index_doc.apply(args=[ - str(library_container.container_key), - ]) + update_library_container_index_doc.apply(args=[ + str(library_container.container_key), + ]) + + +@receiver(LIBRARY_CONTAINER_PUBLISHED) +@only_if_meilisearch_enabled +def library_container_published_handler(**kwargs) -> None: + """ + Update the index for the content library container when its published + version has changed. + """ + library_container = kwargs.get("library_container", None) + if not library_container or not isinstance(library_container, LibraryContainerData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + # The PUBLISHED event is sent for any change to the published version including deletes, so check if it exists: + try: + lib_api.get_container(library_container.container_key) + except lib_api.ContentLibraryContainerNotFound: + log.info(f"Observed published deletion of container {str(library_container.container_key)}.") + # The document should already have been deleted from the search index + # via the DELETED handler, so there's nothing to do now. + return + + update_library_container_index_doc.apply(args=[ + str(library_container.container_key), + ]) @receiver(LIBRARY_CONTAINER_DELETED) @@ -275,3 +321,6 @@ def library_container_deleted(**kwargs) -> None: # Update content library index synchronously to make sure that search index is updated before # the frontend invalidates/refetches results. This is only a single document update so is very fast. delete_library_container_index_doc.apply(args=[str(library_container.container_key)]) + # TODO: post-Teak, move all the celery tasks directly inline into this handlers? Because now the + # events are emitted in an [async] worker, so it doesn't matter if the handlers are synchronous. + # See https://github.com/openedx/edx-platform/pull/36640 discussion. diff --git a/openedx/core/djangoapps/content/search/tasks.py b/openedx/core/djangoapps/content/search/tasks.py index 1ab77aba38..5015f6912b 100644 --- a/openedx/core/djangoapps/content/search/tasks.py +++ b/openedx/core/djangoapps/content/search/tasks.py @@ -86,9 +86,6 @@ def update_content_library_index_docs(library_key_str: str) -> None: log.info("Updating content index documents for library with id: %s", library_key) api.upsert_content_library_index_docs(library_key) - # Delete all documents in this library that were not published by above function - # as this task is also triggered on discard event. - api.delete_all_draft_docs_for_library(library_key) @shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 492d1b5f76..90b6a407e7 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -734,21 +734,6 @@ class TestSearchApi(ModuleStoreTestCase): [self.doc_problem1, self.doc_problem2] ) - @override_settings(MEILISEARCH_ENABLED=True) - def test_delete_all_drafts(self, mock_meilisearch): - """ - Test deleting all draft documents from the index. - """ - api.delete_all_draft_docs_for_library(self.library.key) - - delete_filter = [ - f'context_key="{self.library.key}"', - ['last_published IS EMPTY', 'last_published IS NULL'], - ] - mock_meilisearch.return_value.index.return_value.delete_documents.assert_called_once_with( - filter=delete_filter - ) - @override_settings(MEILISEARCH_ENABLED=True) def test_index_tags_in_collections(self, mock_meilisearch): # Tag collection diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index d440055448..d693ff30d7 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -63,10 +63,9 @@ from .containers import ( ContainerMetadata, ContainerType, ) -from .libraries import ( - library_collection_locator, - PublishableItem, -) +from .collections import library_collection_locator +from .libraries import PublishableItem +from .. import tasks # This content_libraries API is sometimes imported in the LMS (should we prevent that?), but the content_staging app # cannot be. For now we only need this one type import at module scope, so only import it during type checks. @@ -836,24 +835,13 @@ def publish_component_changes(usage_key: LibraryUsageLocatorV2, user: UserType): # The core publishing API is based on draft objects, so find the draft that corresponds to this component: drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter(entity__key=component.key) # Publish the component and update anything that needs to be updated (e.g. search index): - authoring_api.publish_from_drafts(learning_package.id, draft_qset=drafts_to_publish, published_by=user.id) - LIBRARY_BLOCK_UPDATED.send_event( - library_block=LibraryBlockData( - library_key=usage_key.lib_key, - usage_key=usage_key, - ) + publish_log = authoring_api.publish_from_drafts( + learning_package.id, draft_qset=drafts_to_publish, published_by=user.id, ) - - # For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger - # container indexing asynchronously. - affected_containers = get_containers_contains_component(usage_key) - for container in affected_containers: - LIBRARY_CONTAINER_UPDATED.send_event( - library_container=LibraryContainerData( - container_key=container.container_key, - background=True, - ) - ) + # Since this is a single component, it should be safe to process synchronously and in-process: + tasks.send_events_after_publish(publish_log.pk, str(library_key)) + # IF this is found to be a performance issue, we could instead make it async where necessary: + # tasks.wait_for_post_publish_events(publish_log, library_key=library_key) def _component_exists(usage_key: UsageKeyV2) -> bool: diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 36072caf82..2adad97970 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -4,7 +4,7 @@ API for containers (Sections, Subsections, Units) in Content Libraries from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from enum import Enum import logging from uuid import uuid4 @@ -14,13 +14,11 @@ from opaque_keys.edx.keys import UsageKeyV2 from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.content_authoring.data import ( ContentObjectChangedData, - LibraryBlockData, LibraryCollectionData, LibraryContainerData, ) from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - LIBRARY_BLOCK_UPDATED, LIBRARY_COLLECTION_UPDATED, LIBRARY_CONTAINER_CREATED, LIBRARY_CONTAINER_DELETED, @@ -34,8 +32,9 @@ from openedx.core.djangoapps.xblock.api import get_component_from_usage_key from ..models import ContentLibrary from .exceptions import ContentLibraryContainerNotFound -from .libraries import PublishableItem, library_component_usage_key +from .libraries import PublishableItem from .block_metadata import LibraryXBlockMetadata +from .. import tasks # The public API is only the following symbols: __all__ = [ @@ -250,7 +249,7 @@ def create_container( content_library.learning_package_id, key=slug, title=title, - created=created or datetime.now(), + created=created or datetime.now(tz=timezone.utc), created_by=user_id, ) case _: @@ -280,7 +279,7 @@ def update_container( unit_version = authoring_api.create_next_unit_version( container.unit, title=display_name, - created=datetime.now(), + created=datetime.now(tz=timezone.utc), created_by=user_id, ) @@ -427,7 +426,7 @@ def update_container_children( new_version = authoring_api.create_next_unit_version( container.unit, components=components, # type: ignore[arg-type] - created=datetime.now(), + created=datetime.now(tz=timezone.utc), created_by=user_id, entities_action=entities_action, ) @@ -478,21 +477,6 @@ def publish_container_changes(container_key: LibraryContainerLocator, user_id: i draft_qset=drafts_to_publish, published_by=user_id, ) - # Update anything that needs to be updated (e.g. search index): - for record in publish_log.records.select_related("entity", "entity__container", "entity__component").all(): - if hasattr(record.entity, "component"): - # This is a child component like an XBLock in a Unit that was published: - usage_key = library_component_usage_key(library_key, record.entity.component) - LIBRARY_BLOCK_UPDATED.send_event( - library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key) - ) - elif hasattr(record.entity, "container"): - # This is a child container like a Unit, or is the same "container" we published above. - LIBRARY_CONTAINER_UPDATED.send_event( - library_container=LibraryContainerData(container_key=container_key) - ) - else: - log.warning( - f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation " - "but is of unknown type." - ) + # Update the search index (and anything else) for the affected container + blocks + # This is mostly synchronous but may complete some work asynchronously if there are a lot of changes. + tasks.wait_for_post_publish_events(publish_log, library_key) diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index d09e59f379..546e7520b5 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -55,15 +55,11 @@ from django.utils.translation import gettext as _ from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.content_authoring.data import ( ContentLibraryData, - LibraryCollectionData, - ContentObjectChangedData, ) from openedx_events.content_authoring.signals import ( CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, - LIBRARY_COLLECTION_UPDATED, - CONTENT_OBJECT_ASSOCIATIONS_CHANGED, ) from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Component @@ -75,7 +71,7 @@ from openedx.core.types import User as UserType from .. import permissions from ..constants import ALL_RIGHTS_RESERVED from ..models import ContentLibrary, ContentLibraryPermission -from .collections import library_collection_locator +from .. import tasks from .exceptions import ( LibraryAlreadyExists, LibraryPermissionIntegrityError, @@ -666,14 +662,15 @@ def publish_changes(library_key: LibraryLocatorV2, user_id: int | None = None): """ learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package assert learning_package is not None # shouldn't happen but it's technically possible. - authoring_api.publish_all_drafts(learning_package.id, published_by=user_id) + publish_log = authoring_api.publish_all_drafts(learning_package.id, published_by=user_id) - CONTENT_LIBRARY_UPDATED.send_event( - content_library=ContentLibraryData( - library_key=library_key, - update_blocks=True - ) - ) + # Update the search index (and anything else) for the affected blocks + # This is mostly synchronous but may complete some work asynchronously if there are a lot of changes. + tasks.wait_for_post_publish_events(publish_log, library_key) + + # Unlike revert_changes below, we do not have to re-index collections, + # because publishing changes does not affect the component counts, and + # collections themselves don't have draft/published/unpublished status. def revert_changes(library_key: LibraryLocatorV2, user_id: int | None = None) -> None: @@ -683,46 +680,8 @@ def revert_changes(library_key: LibraryLocatorV2, user_id: int | None = None) -> """ learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package assert learning_package is not None # shouldn't happen but it's technically possible. - authoring_api.reset_drafts_to_published(learning_package.id, reset_by=user_id) + with authoring_api.bulk_draft_changes_for(learning_package.id) as draft_change_log: + authoring_api.reset_drafts_to_published(learning_package.id, reset_by=user_id) - CONTENT_LIBRARY_UPDATED.send_event( - content_library=ContentLibraryData( - library_key=library_key, - update_blocks=True - ) - ) - - # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger - # collection indexing asynchronously. - # - # This is to update component counts in all library collections, - # because there may be components that have been discarded in the revert. - for collection in authoring_api.get_collections(learning_package.id): - LIBRARY_COLLECTION_UPDATED.send_event( - library_collection=LibraryCollectionData( - collection_key=library_collection_locator( - library_key=library_key, - collection_key=collection.key, - ), - background=True, - ) - ) - - # Reindex components that are in collections - # - # Use case: When a component that was within a collection has been deleted - # and the changes are reverted, the component should appear in the - # collection again. - components_in_collections = authoring_api.get_components( - learning_package.id, draft=True, namespace='xblock.v1', - ).filter(publishable_entity__collections__isnull=False) - - for component in components_in_collections: - usage_key = library_component_usage_key(library_key, component) - - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( - content_object=ContentObjectChangedData( - object_id=str(usage_key), - changes=["collections"], - ), - ) + # Call the event handlers as needed. + tasks.wait_for_post_revert_events(draft_change_log, library_key) diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py index b76101e1c6..b472126e8c 100644 --- a/openedx/core/djangoapps/content_libraries/tasks.py +++ b/openedx/core/djangoapps/content_libraries/tasks.py @@ -23,11 +23,34 @@ from celery_utils.logged_task import LoggedTask from celery.utils.log import get_task_logger from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import ( + BlockUsageLocator, + LibraryCollectionLocator, + LibraryContainerLocator, + LibraryLocatorV2, +) +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import DraftChangeLog, PublishLog +from openedx_events.content_authoring.data import ( + LibraryBlockData, + LibraryCollectionData, + LibraryContainerData, +) +from openedx_events.content_authoring.signals import ( + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, +) from user_tasks.tasks import UserTask, UserTaskStatus from xblock.fields import Scope -from opaque_keys.edx.locator import BlockUsageLocator from openedx.core.lib import ensure_cms from xmodule.capa_block import ProblemBlock from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LegacyLibraryContentBlock @@ -39,10 +62,197 @@ from xmodule.modulestore.mixed import MixedModuleStore from . import api from .models import ContentLibraryBlockImportTask -logger = logging.getLogger(__name__) +log = logging.getLogger(__name__) TASK_LOGGER = get_task_logger(__name__) +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def send_events_after_publish(publish_log_pk: int, library_key_str: str) -> None: + """ + Send events to trigger actions like updating the search index, after we've + published some items in a library. + + We use the PublishLog record so we can detect exactly what was changed, + including any auto-published changes like child items in containers. + + This happens in a celery task so that it can be run asynchronously if + needed, because the "publish all changes" action can potentially publish + hundreds or even thousands of components/containers at once, and synchronous + event handlers like updating the search index may a while to complete in + that case. + """ + publish_log = PublishLog.objects.get(pk=publish_log_pk) + library_key = LibraryLocatorV2.from_string(library_key_str) + affected_entities = publish_log.records.select_related("entity", "entity__container", "entity__component").all() + affected_containers: set[LibraryContainerLocator] = set() + + # Update anything that needs to be updated (e.g. search index): + for record in affected_entities: + if hasattr(record.entity, "component"): + usage_key = api.library_component_usage_key(library_key, record.entity.component) + # Note that this item may be newly created, updated, or even deleted - but all we care about for this event + # is that the published version is now different. Only for draft changes do we send differentiated events. + LIBRARY_BLOCK_PUBLISHED.send_event( + library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key) + ) + # Publishing a container will auto-publish its children, but publishing a single component or all changes + # in the library will NOT usually include any parent containers. But we do need to notify listeners that the + # parent container(s) have changed, e.g. so the search index can update the "has_unpublished_changes" + for parent_container in api.get_containers_contains_component(usage_key): + affected_containers.add(parent_container.container_key) + # TODO: should this be a CONTAINER_CHILD_PUBLISHED event instead of CONTAINER_PUBLISHED ? + elif hasattr(record.entity, "container"): + container_key = api.library_container_locator(library_key, record.entity.container) + affected_containers.add(container_key) + else: + log.warning( + f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation " + "but is of unknown type." + ) + + for container_key in affected_containers: + LIBRARY_CONTAINER_PUBLISHED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + + +def wait_for_post_publish_events(publish_log: PublishLog, library_key: LibraryLocatorV2): + """ + After publishing some changes, trigger the required event handlers (e.g. + update the search index). Try to wait for that to complete before returning, + up to some reasonable timeout, and then finish anything remaining + asynchonrously. + """ + # Update the search index (and anything else) for the affected blocks + result = send_events_after_publish.apply_async(args=(publish_log.pk, str(library_key))) + # Try waiting a bit for those post-publish events to be handled: + try: + result.get(timeout=15) + except TimeoutError: + pass + # This is fine! The search index is still being updated, and/or other + # event handlers are still following up on the results, but the publish + # already *did* succeed, and the events will continue to be processed in + # the background by the celery worker until everything is updated. + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def send_events_after_revert(draft_change_log_id: int, library_key_str: str) -> None: + """ + Send events to trigger actions like updating the search index, after we've + reverted some unpublished changes in a library. + + See notes on the analogous function above, send_events_after_publish. + """ + try: + draft_change_log = DraftChangeLog.objects.get(id=draft_change_log_id) + except DraftChangeLog.DoesNotExist: + # When a revert operation is a no-op, Learning Core deletes the empty + # DraftChangeLog, so we'll assume that's what happened here. + log.info(f"Library revert in {library_key_str} did not result in any changes.") + return + + library_key = LibraryLocatorV2.from_string(library_key_str) + affected_entities = draft_change_log.records.select_related( + "entity", "entity__container", "entity__component", + ).all() + + created_container_keys: set[LibraryContainerLocator] = set() + updated_container_keys: set[LibraryContainerLocator] = set() + deleted_container_keys: set[LibraryContainerLocator] = set() + affected_collection_keys: set[LibraryCollectionLocator] = set() + + # Update anything that needs to be updated (e.g. search index): + for record in affected_entities: + # This will be true if the entity was [soft] deleted, but we're now reverting that deletion: + is_undeleted = (record.old_version is None and record.new_version is not None) + # This will be true if the entity was created and we're now deleting it by reverting that creation: + is_deleted = (record.old_version is not None and record.new_version is None) + if hasattr(record.entity, "component"): + usage_key = api.library_component_usage_key(library_key, record.entity.component) + event = LIBRARY_BLOCK_UPDATED + if is_deleted: + event = LIBRARY_BLOCK_DELETED + elif is_undeleted: + event = LIBRARY_BLOCK_CREATED + event.send_event(library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key)) + # If any containers contain this component, their child list / component count may need to be updated + # e.g. if this was a newly created component in the container and is now deleted, or this was deleted and + # is now restored. + for parent_container in api.get_containers_contains_component(usage_key): + updated_container_keys.add(parent_container.container_key) + + # TODO: do we also need to send CONTENT_OBJECT_ASSOCIATIONS_CHANGED for this component, or is + # LIBRARY_BLOCK_UPDATED sufficient? + elif hasattr(record.entity, "container"): + container_key = api.library_container_locator(library_key, record.entity.container) + if is_deleted: + deleted_container_keys.add(container_key) + elif is_undeleted: + created_container_keys.add(container_key) + else: + updated_container_keys.add(container_key) + else: + log.warning( + f"PublishableEntity {record.entity.pk} / {record.entity.key} 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 authoring_api.get_entity_collections( + record.entity.learning_package_id, record.entity.key, + ): + collection_key = api.library_collection_locator( + library_key=library_key, + collection_key=parent_collection.key, + ) + affected_collection_keys.add(collection_key) + + for container_key in deleted_container_keys: + LIBRARY_CONTAINER_DELETED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + # Don't bother sending UPDATED events for these containers that are now deleted + created_container_keys.discard(container_key) + + for container_key in created_container_keys: + LIBRARY_CONTAINER_CREATED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + + for container_key in updated_container_keys: + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + + for collection_key in affected_collection_keys: + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData(collection_key=collection_key) + ) + + +def wait_for_post_revert_events(draft_change_log: DraftChangeLog, library_key: LibraryLocatorV2): + """ + After discard all changes in a library, trigger the required event handlers + (e.g. update the search index). Try to wait for that to complete before + returning, up to some reasonable timeout, and then finish anything remaining + asynchonrously. + """ + # Update the search index (and anything else) for the affected blocks + result = send_events_after_revert.apply_async(args=(draft_change_log.pk, str(library_key))) + # Try waiting a bit for those post-publish events to be handled: + try: + result.get(timeout=15) + except TimeoutError: + pass + # This is fine! The search index is still being updated, and/or other + # event handlers are still following up on the results, but the revert + # already *did* succeed, and the events will continue to be processed in + # the background by the celery worker until everything is updated. + + @shared_task(base=LoggedTask) @set_code_owner_attribute def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_block_id_suffix=True): @@ -57,9 +267,9 @@ def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_ def on_progress(block_key, block_num, block_count, exception=None): if exception: - logger.exception('Import block failed: %s', block_key) + log.exception('Import block failed: %s', block_key) else: - logger.info('Import block succesful: %s', block_key) + log.info('Import block succesful: %s', block_key) import_task.save_progress(block_num / block_count) edx_client = api.EdxModulestoreImportClient( @@ -121,6 +331,9 @@ def sync_from_library( ) -> None: """ Celery task to update the children of the library_content block at `dest_block_id`. + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ set_code_owner_attribute_from_module(__name__) store = modulestore() @@ -143,6 +356,9 @@ def duplicate_children( ) -> None: """ Celery task to duplicate the children from `source_block_id` to `dest_block_id`. + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ set_code_owner_attribute_from_module(__name__) store = modulestore() @@ -180,6 +396,9 @@ def _sync_children( Implementation helper for `sync_from_library` and `duplicate_children` Celery tasks. Can update children with a specific library `library_version`, or latest (`library_version=None`). + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ source_blocks = [] library_key = dest_block.source_library_key.for_branch( @@ -220,6 +439,9 @@ def _copy_overrides( ) -> None: """ Copy any overrides the user has made on children of `source` over to the children of `dest_block`, recursively. + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ for field in source_block.fields.values(): if field.scope == Scope.settings and field.is_set_on(source_block): diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 6068d9c20e..e5a9f5f12e 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -8,6 +8,8 @@ from urllib.parse import urlencode from organizations.models import Organization from rest_framework.test import APITransactionTestCase, APIClient +from opaque_keys.edx.keys import ContainerKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.json_request import JsonResponse as SpecialJsonResponse @@ -25,6 +27,7 @@ URL_LIB_LINKS = URL_LIB_DETAIL + 'links/' # Get the list of links in this libra URL_LIB_COMMIT = URL_LIB_DETAIL + 'commit/' # Commit (POST) or revert (DELETE) all pending changes to this library URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this library, or add a new one URL_LIB_CONTAINERS = URL_LIB_DETAIL + 'containers/' # Create a new container in this library +URL_LIB_COLLECTIONS = URL_LIB_DETAIL + 'collections/' # Create a new collection in this library URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authorized to use this library URL_LIB_TEAM_USER = URL_LIB_TEAM + 'user/{username}/' # Add/edit/remove a user's permission to use this library URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library @@ -39,6 +42,8 @@ URL_LIB_CONTAINER_COMPONENTS = URL_LIB_CONTAINER + 'children/' # Get, add or de URL_LIB_CONTAINER_RESTORE = URL_LIB_CONTAINER + 'restore/' # Restore a deleted container URL_LIB_CONTAINER_COLLECTIONS = URL_LIB_CONTAINER + 'collections/' # Handle associated collections URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to the specified container + children +URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' # Get a collection in this library +URL_LIB_COLLECTION_ITEMS = URL_LIB_COLLECTION + 'items/' # Get a collection in this library URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/' URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/' @@ -70,11 +75,6 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): entire response has some specific shape. That way, things like adding new fields to an API response, which are backwards compatible, won't break any tests, but backwards-incompatible API changes will. - - WARNING: every test should have a unique library slug, because even though - the django/mysql database gets reset for each test case, the lookup between - library slug and bundle UUID does not because it's assumed to be immutable - and cached forever. """ def setUp(self): @@ -379,24 +379,24 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): data["slug"] = slug return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), data, expect_response) - def _get_container(self, container_key: str, expect_response=200): + def _get_container(self, container_key: ContainerKey | str, expect_response=200): """ Get a container (unit etc.) """ return self._api('get', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) - def _update_container(self, container_key: str, display_name: str, expect_response=200): + def _update_container(self, container_key: ContainerKey | str, display_name: str, expect_response=200): """ Update a container (unit etc.) """ data = {"display_name": display_name} return self._api('patch', URL_LIB_CONTAINER.format(container_key=container_key), data, expect_response) - def _delete_container(self, container_key: str, expect_response=204): + def _delete_container(self, container_key: ContainerKey | str, expect_response=204): """ Delete a container (unit etc.) """ return self._api('delete', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) - def _restore_container(self, container_key: str, expect_response=204): + def _restore_container(self, container_key: ContainerKey | str, expect_response=204): """ Restore a deleted a container (unit etc.) """ return self._api('post', URL_LIB_CONTAINER_RESTORE.format(container_key=container_key), None, expect_response) - def _get_container_components(self, container_key: str, expect_response=200): + def _get_container_components(self, container_key: ContainerKey | str, expect_response=200): """ Get container components""" return self._api( 'get', @@ -407,7 +407,7 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): def _add_container_components( self, - container_key: str, + container_key: ContainerKey | str, children_ids: list[str], expect_response=200, ): @@ -421,7 +421,7 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): def _remove_container_components( self, - container_key: str, + container_key: ContainerKey | str, children_ids: list[str], expect_response=200, ): @@ -435,7 +435,7 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): def _patch_container_components( self, - container_key: str, + container_key: ContainerKey | str, children_ids: list[str], expect_response=200, ): @@ -449,7 +449,7 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): def _patch_container_collections( self, - container_key: str, + container_key: ContainerKey | str, collection_keys: list[str], expect_response=200, ): @@ -461,6 +461,52 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): expect_response ) - def _publish_container(self, container_key, expect_response=200): + def _publish_container(self, container_key: ContainerKey | str, expect_response=200): """ Publish all changes in the specified container + children """ return self._api('post', URL_LIB_CONTAINER_PUBLISH.format(container_key=container_key), None, expect_response) + + def _create_collection( + self, + lib_key: LibraryLocatorV2 | str, + title: str, + description: str = "", + expect_response=200, + ): + """ Create a new collection in this library """ + data = {"title": title, "description": description} + return self._api('post', URL_LIB_COLLECTIONS.format(lib_key=lib_key), data, expect_response) + + def _soft_delete_collection(self, collection_key: LibraryCollectionLocator, expect_response=204): + """ Soft delete (disable) a collection """ + url = URL_LIB_COLLECTION.format(lib_key=collection_key.lib_key, collection_key=collection_key.collection_id) + return self._api('delete', url, {}, expect_response) + + def _update_collection( + self, + collection_key: LibraryCollectionLocator, + title: str | None = None, + description: str | None = None, + expect_response=200, + ): + """ Update a collection's title/description """ + data = {} + if title is not None: + data["title"] = title + if description is not None: + data["description"] = description + url = URL_LIB_COLLECTION.format(lib_key=collection_key.lib_key, collection_key=collection_key.collection_id) + return self._api('patch', url, data, expect_response) + + def _add_items_to_collection( + self, + collection_key: LibraryCollectionLocator, + item_keys: list[str | UsageKey | ContainerKey], + expect_response=200, + ): + """ Add components/containers to a collection """ + data = {"usage_keys": [str(k) for k in item_keys]} + url = URL_LIB_COLLECTION_ITEMS.format( + lib_key=collection_key.lib_key, + collection_key=collection_key.collection_id, + ) + return self._api('patch', url, data, expect_response) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index 8dbb5e1194..3a1121da38 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -25,7 +25,6 @@ from openedx_events.content_authoring.signals import ( LIBRARY_COLLECTION_UPDATED, LIBRARY_CONTAINER_UPDATED, ) -from openedx_events.tests.utils import OpenEdxEventsTestMixin from openedx_learning.api import authoring as authoring_api from .. import api @@ -259,30 +258,12 @@ class EdxApiImportClientTest(TestCase): mock_publish_changes.assert_not_called() -class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest): """ Tests for Content Library API collections methods. Same guidelines as ContentLibrariesTestCase. """ - ENABLED_OPENEDX_EVENTS = [ - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.event_type, - LIBRARY_COLLECTION_CREATED.event_type, - LIBRARY_COLLECTION_DELETED.event_type, - LIBRARY_COLLECTION_UPDATED.event_type, - ] - - @classmethod - def setUpClass(cls): - """ - Set up class method for the Test class. - - TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on - OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, - so we're following a pattern here. But that pattern doesn't really make sense. - """ - super().setUpClass() - cls.start_events_isolation() def setUp(self): super().setUp() @@ -555,45 +536,28 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col2.key).entities.all()) == 1 assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col3.key).entities.all()) == 1 - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=self.lib2_problem_block["id"], - changes=["collections"], - ), - }, - event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": LIBRARY_COLLECTION_UPDATED, - "sender": None, - "library_collection": LibraryCollectionData( - collection_key=api.library_collection_locator( - self.lib2.library_key, - collection_key=self.col2.key, - ), - background=True, - ), - }, - collection_update_event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": LIBRARY_COLLECTION_UPDATED, - "sender": None, - "library_collection": LibraryCollectionData( - collection_key=api.library_collection_locator( - self.lib2.library_key, - collection_key=self.col3.key, - ), - background=True, - ), - }, - collection_update_event_receiver.call_args_list[1].kwargs, - ) + assert { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=self.lib2_problem_block["id"], + changes=["collections"], + ), + }.items() <= event_receiver.call_args_list[0].kwargs.items() + + assert len(collection_update_event_receiver.call_args_list) == 2 + collection_update_events = [call.kwargs for call in collection_update_event_receiver.call_args_list] + 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), + background=True, + ), + LibraryCollectionData( + collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col3.key), + background=True, + ) + } def test_delete_library_block(self): api.update_library_collection_items( @@ -690,72 +654,46 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe ) def test_add_component_and_revert(self): - # Add component and publish - api.update_library_collection_items( - self.lib1.library_key, - self.col1.key, - opaque_keys=[ - UsageKey.from_string(self.lib1_problem_block["id"]), - ], - ) + # Publish changes api.publish_changes(self.lib1.library_key) - # Add component and revert + # Create a new component that will only exist as a draft + new_problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problemNEW", + ) + + # 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, opaque_keys=[ UsageKey.from_string(self.lib1_html_block["id"]), + UsageKey.from_string(new_problem_block["id"]), ], ) - event_receiver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver) collection_update_event_receiver = mock.Mock() LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) api.revert_changes(self.lib1.library_key) assert collection_update_event_receiver.call_count == 1 - assert event_receiver.call_count == 2 - self.assertDictContainsSubset( - { - "signal": LIBRARY_COLLECTION_UPDATED, - "sender": None, - "library_collection": LibraryCollectionData( - collection_key=api.library_collection_locator( - self.lib1.library_key, - collection_key=self.col1.key, - ), - background=True, + assert { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key=self.col1.key, ), - }, - collection_update_event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(self.lib1_problem_block["id"]), - changes=["collections"], - ), - }, - event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(self.lib1_html_block["id"]), - changes=["collections"], - ), - }, - event_receiver.call_args_list[1].kwargs, - ) + ), + }.items() <= collection_update_event_receiver.call_args_list[0].kwargs.items() def test_delete_component_and_revert(self): + """ + When a component is deleted and then the delete is reverted, signals + will be emitted to update any containing collections. + """ # Add components and publish api.update_library_collection_items( self.lib1.library_key, @@ -770,72 +708,28 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe # Delete component and revert api.delete_library_block(UsageKey.from_string(self.lib1_problem_block["id"])) - event_receiver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver) collection_update_event_receiver = mock.Mock() LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) api.revert_changes(self.lib1.library_key) assert collection_update_event_receiver.call_count == 1 - assert event_receiver.call_count == 2 - self.assertDictContainsSubset( - { - "signal": LIBRARY_COLLECTION_UPDATED, - "sender": None, - "library_collection": LibraryCollectionData( - collection_key=api.library_collection_locator( - self.lib1.library_key, - collection_key=self.col1.key, - ), - background=True, + assert { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key=self.col1.key, ), - }, - collection_update_event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(self.lib1_problem_block["id"]), - changes=["collections"], - ), - }, - event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(self.lib1_html_block["id"]), - changes=["collections"], - ), - }, - event_receiver.call_args_list[1].kwargs, - ) + ), + }.items() <= collection_update_event_receiver.call_args_list[0].kwargs.items() -class ContentLibraryContainersTest(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class ContentLibraryContainersTest(ContentLibrariesRestApiTest): """ Tests for Content Library API containers methods. """ - ENABLED_OPENEDX_EVENTS = [ - LIBRARY_CONTAINER_UPDATED.event_type, - ] - - @classmethod - def setUpClass(cls): - """ - Set up class method for the Test class. - - TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on - OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, - so we're following a pattern here. But that pattern doesn't really make sense. - """ - super().setUpClass() - cls.start_events_isolation() def setUp(self): super().setUp() @@ -944,3 +838,29 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest, OpenEdxEventsTes self._set_library_block_fields(self.html_block_usage_key, {"data": block_olx, "metadata": {}}) self._validate_calls_of_html_block(container_update_event_receiver) + + def test_delete_component_and_revert(self): + """ + When a component is deleted and then the delete is reverted, signals + will be emitted to update any containing containers. + """ + # Add components and publish + api.update_container_children(self.unit1.container_key, [ + UsageKey.from_string(self.problem_block["id"]), + ], user_id=None) + api.publish_changes(self.lib1.library_key) + + # Delete component and revert + api.delete_library_block(UsageKey.from_string(self.problem_block["id"])) + + container_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_event_receiver) + + api.revert_changes(self.lib1.library_key) + + assert container_event_receiver.call_count == 1 + assert { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData(container_key=self.unit1.container_key), + }.items() <= container_event_receiver.call_args_list[0].kwargs.items() diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index db6456a14a..6c59c8c086 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -2,20 +2,11 @@ Tests for Learning-Core-based Content Libraries """ from datetime import datetime, timezone -from unittest import mock import ddt from freezegun import freeze_time -from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 -from openedx_events.content_authoring.data import LibraryContainerData -from openedx_events.content_authoring.signals import ( - LIBRARY_BLOCK_UPDATED, - LIBRARY_CONTAINER_CREATED, - LIBRARY_CONTAINER_DELETED, - LIBRARY_CONTAINER_UPDATED, -) -from openedx_events.tests.utils import OpenEdxEventsTestMixin +from opaque_keys.edx.locator import LibraryLocatorV2 from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.content_libraries import api @@ -25,7 +16,7 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms @skip_unless_cms @ddt.ddt -class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): +class ContainersTestCase(ContentLibrariesRestApiTest): """ Tests for containers (Sections, Subsections, Units) in Content Libraries. @@ -43,12 +34,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): new fields to an API response, which are backwards compatible, won't break any tests, but backwards-incompatible API changes will. """ - ENABLED_OPENEDX_EVENTS = [ - LIBRARY_BLOCK_UPDATED.event_type, - LIBRARY_CONTAINER_CREATED.event_type, - LIBRARY_CONTAINER_DELETED.event_type, - LIBRARY_CONTAINER_UPDATED.event_type, - ] def test_unit_crud(self): """ @@ -57,15 +42,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") lib_key = LibraryLocatorV2.from_string(lib["id"]) - create_receiver = mock.Mock() - LIBRARY_CONTAINER_CREATED.connect(create_receiver) - - update_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(update_receiver) - - delete_receiver = mock.Mock() - LIBRARY_CONTAINER_DELETED.connect(delete_receiver) - # Create a unit: create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc) with freeze_time(create_date): @@ -85,20 +61,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): } self.assertDictContainsEntries(container_data, expected_data) - assert create_receiver.call_count == 1 - container_key = LibraryContainerLocator.from_string( - "lct:CL-TEST:containers:unit:u1", - ) - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_CREATED, - "sender": None, - "library_container": LibraryContainerData( - container_key, - ), - }, - create_receiver.call_args_list[0].kwargs, - ) # Fetch the unit: unit_as_read = self._get_container(container_data["id"]) @@ -113,18 +75,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): expected_data['display_name'] = 'Unit ABC' self.assertDictContainsEntries(container_data, expected_data) - assert update_receiver.call_count == 1 - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key, - ), - }, - update_receiver.call_args_list[0].kwargs, - ) - # Re-fetch the unit unit_as_re_read = self._get_container(container_data["id"]) # make sure it contains the same data when we read it back: @@ -133,17 +83,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): # Delete the unit self._delete_container(container_data["id"]) self._get_container(container_data["id"], expect_response=404) - assert delete_receiver.call_count == 1 - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_DELETED, - "sender": None, - "library_container": LibraryContainerData( - container_key, - ), - }, - delete_receiver.call_args_list[0].kwargs, - ) def test_unit_permissions(self): """ @@ -186,8 +125,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): """ Test that we can add and get unit children components """ - update_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(update_receiver) lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") lib_key = LibraryLocatorV2.from_string(lib["id"]) @@ -212,18 +149,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): container_data["id"], children_ids=[problem_block_2["id"], html_block_2["id"]] ) - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string( - container_data["id"], - ), - ), - }, - update_receiver.call_args_list[0].kwargs, - ) data = self._get_container_components(container_data["id"]) # Verify total number of components to be 2 + 2 = 4 assert len(data) == 4 @@ -236,8 +161,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): """ Test that we can remove unit children components """ - update_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(update_receiver) lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") lib_key = LibraryLocatorV2.from_string(lib["id"]) @@ -262,25 +185,11 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): assert len(data) == 2 assert data[0]['id'] == html_block['id'] assert data[1]['id'] == html_block_2['id'] - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string( - container_data["id"], - ), - ), - }, - update_receiver.call_args_list[0].kwargs, - ) def test_unit_replace_children(self): """ Test that we can completely replace/reorder unit children components. """ - update_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(update_receiver) lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") lib_key = LibraryLocatorV2.from_string(lib["id"]) @@ -324,18 +233,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): assert len(data) == 2 assert data[0]['id'] == new_problem_block['id'] assert data[1]['id'] == new_html_block['id'] - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string( - container_data["id"], - ), - ), - }, - update_receiver.call_args_list[0].kwargs, - ) def test_restore_unit(self): """ @@ -352,9 +249,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): # Delete the unit self._delete_container(container_data["id"]) - create_receiver = mock.Mock() - LIBRARY_CONTAINER_CREATED.connect(create_receiver) - # Restore container self._restore_container(container_data["id"]) new_container_data = self._get_container(container_data["id"]) @@ -372,20 +266,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): 'collections': [], } - self.assertDictContainsEntries(new_container_data, expected_data) - - assert create_receiver.call_count == 1 - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_CREATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string("lct:CL-TEST:containers:unit:u1"), - ), - }, - create_receiver.call_args_list[0].kwargs, - ) - def test_container_collections(self): # Create a library lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") @@ -444,12 +324,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): c2_before = self._get_container(container2["id"]) assert c2_before["has_unpublished_changes"] - # Set up event receivers after the initial mock data setup is complete: - updated_container_receiver = mock.Mock() - updated_block_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(updated_container_receiver) - LIBRARY_BLOCK_UPDATED.connect(updated_block_receiver) - # Now publish only Container 1 self._publish_container(container1["id"]) @@ -476,27 +350,3 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): assert c2_components_after[1]["id"] == html_block2["id"] assert c2_components_after[1]["has_unpublished_changes"] # unaffected assert c2_components_after[1]["published_by"] is None - - # Make sure that the right events were sent out. - # First, there should be one container updated event: - assert len(updated_container_receiver.call_args_list) == 1 - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string(container1["id"]), - ), - }, - updated_container_receiver.call_args_list[0].kwargs, - ) - - # Second, two XBlock updated events: - assert len(updated_block_receiver.call_args_list) == 2 - updated_block_ids = set( - call.kwargs["library_block"].usage_key for call in updated_block_receiver.call_args_list - ) - assert updated_block_ids == { - LibraryUsageLocatorV2.from_string(problem_block["id"]), - LibraryUsageLocatorV2.from_string(html_block["id"]), - } - assert all(call.kwargs["signal"] == LIBRARY_BLOCK_UPDATED for call in updated_block_receiver.call_args_list) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index e1b34ebfcb..e62a2b6f02 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -3,7 +3,7 @@ Tests for Learning-Core-based Content Libraries """ from datetime import datetime, timezone from unittest import skip -from unittest.mock import Mock, patch +from unittest.mock import patch import ddt from django.contrib.auth.models import Group @@ -11,16 +11,6 @@ from django.test import override_settings from django.test.client import Client from freezegun import freeze_time from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 -from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData -from openedx_events.content_authoring.signals import ( - CONTENT_LIBRARY_CREATED, - CONTENT_LIBRARY_DELETED, - CONTENT_LIBRARY_UPDATED, - LIBRARY_BLOCK_CREATED, - LIBRARY_BLOCK_DELETED, - LIBRARY_BLOCK_UPDATED -) -from openedx_events.tests.utils import OpenEdxEventsTestMixin from organizations.models import Organization from rest_framework.test import APITestCase @@ -31,7 +21,7 @@ from openedx.core.djangoapps.content_libraries.tests.base import ( URL_BLOCK_METADATA_URL, URL_BLOCK_RENDER_VIEW, URL_BLOCK_XBLOCK_HANDLER, - ContentLibrariesRestApiTest + ContentLibrariesRestApiTest, ) from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.djangolib.testing.utils import skip_unless_cms @@ -39,7 +29,7 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms @skip_unless_cms @ddt.ddt -class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class ContentLibrariesTestCase(ContentLibrariesRestApiTest): """ General tests for Learning-Core-based Content Libraries @@ -62,26 +52,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix library slug and bundle UUID does not because it's assumed to be immutable and cached forever. """ - ENABLED_OPENEDX_EVENTS = [ - CONTENT_LIBRARY_CREATED.event_type, - CONTENT_LIBRARY_DELETED.event_type, - CONTENT_LIBRARY_UPDATED.event_type, - LIBRARY_BLOCK_CREATED.event_type, - LIBRARY_BLOCK_DELETED.event_type, - LIBRARY_BLOCK_UPDATED.event_type, - ] - - @classmethod - def setUpClass(cls): - """ - Set up class method for the Test class. - - TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on - OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, - so we're following a pattern here. But that pattern doesn't really make sense. - """ - super().setUpClass() - cls.start_events_isolation() def test_library_crud(self): """ @@ -792,294 +762,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix # Second block should throw error self._add_block_to_library(lib_id, "problem", "problem1", expect_response=400) - def test_content_library_create_event(self): - """ - Check that CONTENT_LIBRARY_CREATED event is sent when a content library is created. - """ - event_receiver = Mock() - CONTENT_LIBRARY_CREATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_event_create", - title="Event Test Library", - description="Testing event in library" - ) - library_key = LibraryLocatorV2.from_string(lib['id']) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": CONTENT_LIBRARY_CREATED, - "sender": None, - "content_library": ContentLibraryData( - library_key=library_key, - update_blocks=False, - ), - }, - event_receiver.call_args.kwargs - ) - - def test_content_library_update_event(self): - """ - Check that CONTENT_LIBRARY_UPDATED event is sent when a content library is updated. - """ - event_receiver = Mock() - CONTENT_LIBRARY_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_event_update", - title="Event Test Library", - description="Testing event in library" - ) - - lib2 = self._update_library(lib["id"], title="New Title") - library_key = LibraryLocatorV2.from_string(lib2['id']) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": CONTENT_LIBRARY_UPDATED, - "sender": None, - "content_library": ContentLibraryData( - library_key=library_key, - update_blocks=False, - ), - }, - event_receiver.call_args.kwargs - ) - - def test_content_library_delete_event(self): - """ - Check that CONTENT_LIBRARY_DELETED event is sent when a content library is deleted. - """ - event_receiver = Mock() - CONTENT_LIBRARY_DELETED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_event_delete", - title="Event Test Library", - description="Testing event in library" - ) - library_key = LibraryLocatorV2.from_string(lib['id']) - - self._delete_library(lib["id"]) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": CONTENT_LIBRARY_DELETED, - "sender": None, - "content_library": ContentLibraryData( - library_key=library_key, - update_blocks=False, - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_create_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when a library block is created. - """ - event_receiver = Mock() - LIBRARY_BLOCK_CREATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_create", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - self._add_block_to_library(lib_id, "problem", "problem1") - - library_key = LibraryLocatorV2.from_string(lib_id) - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id="problem1" - ) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_CREATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_olx_update_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when the OLX source is updated. - """ - event_receiver = Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_olx_update", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "problem", "problem1") - block_id = block["id"] - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id="problem1" - ) - - new_olx = """ - - -

This is a normal capa problem with unicode 🔥. It has "maximum attempts" set to **5**.

- - - XBlock metadata only - XBlock data/metadata and associated static asset files - Static asset files for XBlocks and courseware - XModule metadata only - -
-
- """.strip() - - self._set_library_block_olx(block_id, new_olx) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_UPDATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_add_asset_update_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when a static asset is - uploaded associated with the XBlock. - """ - event_receiver = Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_add_asset_update", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "html", "h1") - block_id = block["id"] - self._set_library_block_asset(block_id, "static/test.txt", b"data") - - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="html", - usage_id="h1" - ) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_UPDATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_del_asset_update_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when a static asset is - removed from XBlock. - """ - event_receiver = Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_del_asset_update", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "html", "h321") - block_id = block["id"] - self._set_library_block_asset(block_id, "static/test.txt", b"data") - - self._delete_library_block_asset(block_id, 'static/text.txt') - - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="html", - usage_id="h321" - ) - - event_receiver.assert_called() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_UPDATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_delete_event(self): - """ - Check that LIBRARY_BLOCK_DELETED event is sent when a content library is deleted. - """ - event_receiver = Mock() - LIBRARY_BLOCK_DELETED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_delete", - title="Event Test Library", - description="Testing event in library" - ) - - lib_id = lib["id"] - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "problem", "problem1") - block_id = block['id'] - - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id="problem1" - ) - - self._delete_library_block(block_id) - - event_receiver.assert_called() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_DELETED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - def test_library_paste_xblock(self): """ Check the a new block is created in the library after pasting from clipboard. diff --git a/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py b/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py index e6b379d298..8f45fdd8cc 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py @@ -3,8 +3,6 @@ Tests for Imports from Courses to Learning-Core-based Content Libraries """ import ddt from opaque_keys.edx.locator import LibraryContainerLocator -from openedx_events.content_authoring import signals -from openedx_events.tests.utils import OpenEdxEventsTestMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import ToyCourseFactory @@ -15,15 +13,10 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms @skip_unless_cms @ddt.ddt -class CourseToLibraryTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest, ModuleStoreTestCase): +class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase): """ Tests that involve copying content from courses to libraries. """ - ENABLED_OPENEDX_EVENTS = [ - signals.LIBRARY_CONTAINER_CREATED.event_type, - signals.LIBRARY_CONTAINER_DELETED.event_type, - signals.LIBRARY_CONTAINER_UPDATED.event_type, - ] def test_library_paste_unit_from_course(self): """ diff --git a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py index e9909b7d60..41abeed829 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py @@ -8,7 +8,6 @@ import re import ddt from django.core.exceptions import ValidationError from django.test.utils import override_settings -from openedx_events.tests.utils import OpenEdxEventsTestMixin import pytest from xblock.core import XBlock @@ -22,7 +21,7 @@ from .fields_test_block import FieldsTestBlock @skip_unless_cms @ddt.ddt @override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment? -class LibrariesEmbedViewTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class LibrariesEmbedViewTestCase(ContentLibrariesRestApiTest): """ Tests for embed_view and interacting with draft/published/past versions of Learning-Core-based XBlocks (in Content Libraries). diff --git a/openedx/core/djangoapps/content_libraries/tests/test_events.py b/openedx/core/djangoapps/content_libraries/tests/test_events.py new file mode 100644 index 0000000000..e0e3e73927 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_events.py @@ -0,0 +1,548 @@ +""" +Tests for Learning-Core-based Content Libraries +""" +from opaque_keys.edx.locator import ( + LibraryCollectionLocator, + LibraryContainerLocator, + LibraryLocatorV2, + LibraryUsageLocatorV2, +) +from openedx_events.content_authoring.signals import ( + ContentLibraryData, + LibraryBlockData, + LibraryCollectionData, + LibraryContainerData, + CONTENT_LIBRARY_CREATED, + CONTENT_LIBRARY_DELETED, + CONTENT_LIBRARY_UPDATED, + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, +) + +from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangolib.testing.utils import skip_unless_cms + + +@skip_unless_cms +class ContentLibrariesEventsTestCase(ContentLibrariesRestApiTest): + """ + Event tests for Learning-Core-based Content Libraries + + These tests use the REST API, which in turn relies on the Python API. + """ + # Note: we assume all events are already enabled, as they should be. We do + # NOT use OpenEdxEventsTestMixin, because it disables any events that you + # don't explicitly enable and does so in a way that interferes with other + # test cases, causing flakiness and failures in *other* test modules. + ALL_EVENTS = [ + CONTENT_LIBRARY_CREATED, + CONTENT_LIBRARY_DELETED, + CONTENT_LIBRARY_UPDATED, + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, + ] + + def setUp(self) -> None: + super().setUp() + + # Create some useful data: + self.lib1 = self._create_library( + slug="test_lib_1", + title="Library 1", + description="First Library for testing", + ) + self.lib1_key = LibraryLocatorV2.from_string(self.lib1['id']) + + # From now on, every time an event is emitted, add it to this set: + self.new_events: list[dict] = [] + + def event_receiver(**kwargs) -> None: + self.new_events.append(kwargs) + + for e in self.ALL_EVENTS: + e.connect(event_receiver) + + def disconnect_all() -> None: + for e in self.ALL_EVENTS: + e.disconnect(event_receiver) + + self.addCleanup(disconnect_all) + + def clear_events(self) -> None: + """ Clear the log of events that we've seen so far. """ + self.new_events.clear() + + def expect_new_events(self, *expected_events: dict) -> None: + """ + assert the the specified events have been emitted since the last call to + this function. + """ + # We assume the events may not be in order. Assuming a specific order can lead to flaky tests. + for expected in expected_events: + found = False + for i, actual in enumerate(self.new_events): + if expected.items() <= actual.items(): + self.new_events.pop(i) + found = True + break + if not found: + raise AssertionError(f"Event {expected} not found among actual events: {self.new_events}") + if len(self.new_events) > 0: + raise AssertionError(f"Events were emitted but not expected: {self.new_events}") + self.clear_events() + + ############################## Libraries ################################## + + def test_content_library_crud_events(self) -> None: + """ + Check that CONTENT_LIBRARY_CREATED event is sent when a content library is created, updated, and deleted + """ + # Setup: none + # Action - create a library + new_lib = self._create_library( + slug="new_lib", + title="New Testing Library", + description="New Library for testing", + ) + lib_key = LibraryLocatorV2.from_string(new_lib['id']) + + # Expect a CREATED event: + self.expect_new_events({ + "signal": CONTENT_LIBRARY_CREATED, + "content_library": ContentLibraryData(library_key=lib_key), + }) + + # Action - change the library name: + self._update_library(lib_key=str(lib_key), title="New title") + # Expect an UPDATED event: + self.expect_new_events({ + "signal": CONTENT_LIBRARY_UPDATED, + "content_library": ContentLibraryData(library_key=lib_key), + }) + + # Action - delete the library: + self._delete_library(str(lib_key)) + # Expect a DELETED event: + self.expect_new_events({ + "signal": CONTENT_LIBRARY_DELETED, + "content_library": ContentLibraryData(library_key=lib_key), + }) + + # Should deleting a library send out _DELETED events for all the items in the library too? + + ############################## Components (XBlocks) ################################## + + def test_library_block_create_event(self) -> None: + """ + Check that LIBRARY_BLOCK_CREATED event is sent when a library block is created. + """ + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem1") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + + self.expect_new_events({ + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + def test_library_block_update_and_publish_events(self) -> None: + """ + Check that appropriate events are emitted when an existing block is updated. + """ + # This block should be ignored: + self._add_block_to_library(self.lib1_key, "problem", "problem1") + # This block will be used in the tests: + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem2") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + # Clear events from creating the blocks: + self.clear_events() + + # Now update the block's OLX: + new_olx = """ + + ... + + """.strip() + self._set_library_block_olx(usage_key, new_olx) + self.expect_new_events({ + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Now add a static asset file to the block: + self._set_library_block_asset(usage_key, "static/test.txt", b"data") + self.expect_new_events({ + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Then delete the static asset: + self._delete_library_block_asset(usage_key, 'static/text.txt') + self.expect_new_events({ + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Then publish the block: + self._publish_library_block(usage_key) + self.expect_new_events({ + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + def test_revert_delete(self) -> None: + """ + Test that when a block is deleted and then the delete is reverted, a + _CREATED event is sent. + """ + # This block should be ignored: + self._add_block_to_library(self.lib1_key, "problem", "problem1") + # This block will be used in the tests: + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem2") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + # Publish changes + self._commit_library_changes(self.lib1_key) + # Clear events from creating the blocks: + self.clear_events() + + # Delete the block: + self._delete_library_block(usage_key) + # That should emit a _DELETED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_DELETED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Revert the change: + self._revert_library_changes(self.lib1_key) + # That should result in a _CREATED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + def test_revert_create(self) -> None: + """ + Test that when a block is created and then the changes are reverted, a + _DELETED event is sent. + """ + # Publish any changes from setUp() + self._commit_library_changes(self.lib1_key) + # Clear events: + self.clear_events() + + # Create the block: + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem2") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + # That should result in a _CREATED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Revert the change: + self._revert_library_changes(self.lib1_key) + # That should result in a _DELETED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_DELETED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + ############################## Containers ################################## + + def test_unit_crud(self) -> None: + """ + Test Create, Read, Update, and Delete of a Unit + """ + # Create a unit: + container_data = self._create_container(self.lib1_key, "unit", slug="u1", display_name="Test Unit") + container_key = LibraryContainerLocator.from_string(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + # Update the unit: + self._update_container(container_key, display_name="Unit ABC") + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key), + }) + + # Delete the unit + self._delete_container(container_key) + self._get_container(container_key, expect_response=404) + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_DELETED, + "library_container": LibraryContainerData(container_key), + }) + + def test_publish_all_lib_changes(self) -> None: + """ + Test the events that get emitted when we publish all changes in the library + """ + # Create two containers and add some components + # -> container 1: problem_block, html_block + # -> container 2: html_block, html_block2 + container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None) + container2 = self._create_container(self.lib1_key, "unit", display_name="Bravo Unit", slug=None) + problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False) + html_block = self._add_block_to_library(self.lib1_key, "html", "Html1", can_stand_alone=False) + html_block2 = self._add_block_to_library(self.lib1_key, "html", "Html2", can_stand_alone=False) + self._add_container_components(container1["id"], children_ids=[problem_block["id"], html_block["id"]]) + self._add_container_components(container2["id"], children_ids=[html_block["id"], html_block2["id"]]) + + # Now publish only Container 2 (which will auto-publish both HTML blocks since they're children) + self._publish_container(container2["id"]) + # Container 2 is published, container 1 and its contents is unpublished: + assert self._get_container(container2["id"])["has_unpublished_changes"] is False + assert self._get_container(container1["id"])["has_unpublished_changes"] + assert self._get_library_block(problem_block["id"])["has_unpublished_changes"] + assert self._get_library_block(html_block["id"])["has_unpublished_changes"] is False # in containers 1+2 + + # clear event log up to this point + self.clear_events() + + # Now publish ALL remaining changes in the library: + self._commit_library_changes(self.lib1_key) + # Container 1 is now published: + assert self._get_container(container1["id"])["has_unpublished_changes"] is False + # And publish events were emitted: + self.expect_new_events( + { # An event for container 1 being published: + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container1["id"]), + ), + }, + { # An event for the problem block in container 1: + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(problem_block["id"]), + ), + }, + # The HTML block in container 1 is not part of this publish event group, because it was + # already published when we published container 2 + ) + + def test_publish_child_block(self) -> None: + """ + Test the events that get emitted when we publish changes to a child of a container + """ + # Create a container and a block + container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None) + problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False) + self._add_container_components(container1["id"], children_ids=[problem_block["id"]]) + # Publish all changes + self._commit_library_changes(self.lib1_key) + assert self._get_container(container1["id"])["has_unpublished_changes"] is False + + # Change only the block, not the container: + self._set_library_block_olx(problem_block["id"], "UPDATED") + # Since we modified the block, the container now contains changes (technically it is unchanged and its + # version is the same, but it *contains* unpublished changes) + assert self._get_library_block(problem_block["id"])["has_unpublished_changes"] + assert self._get_container(container1["id"])["has_unpublished_changes"] + # clear event log up to this point + self.clear_events() + + # Now publish ALL remaining changes in the library - should only affect the problem block + self._commit_library_changes(self.lib1_key) + # The container no longer contains unpublished changes: + assert self._get_container(container1["id"])["has_unpublished_changes"] is False + # And publish events were emitted: + self.expect_new_events( + { # An event for container 1 being affected indirectly by the child being published: + # TODO: should this be a CONTAINER_CHILD_PUBLISHED event? + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container1["id"]), + ), + }, + { # An event for the problem block: + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(problem_block["id"]), + ), + }, + ) + + def test_publish_container(self) -> None: + """ + Test the events that get emitted when we publish the changes to a specific container + """ + # Create two containers and add some components + container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None) + container2 = self._create_container(self.lib1_key, "unit", display_name="Bravo Unit", slug=None) + problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False) + html_block = self._add_block_to_library(self.lib1_key, "html", "Html1", can_stand_alone=False) + html_block2 = self._add_block_to_library(self.lib1_key, "html", "Html2", can_stand_alone=False) + self._add_container_components(container1["id"], children_ids=[problem_block["id"], html_block["id"]]) + self._add_container_components(container2["id"], children_ids=[html_block["id"], html_block2["id"]]) + # At first everything is unpublished: + c1_before = self._get_container(container1["id"]) + assert c1_before["has_unpublished_changes"] + c2_before = self._get_container(container2["id"]) + assert c2_before["has_unpublished_changes"] + + # clear event log after the initial mock data setup is complete: + self.clear_events() + + # Now publish only Container 1 + self._publish_container(container1["id"]) + + # Now it is published: + c1_after = self._get_container(container1["id"]) + assert c1_after["has_unpublished_changes"] is False + # And publish events were emitted: + self.expect_new_events( + { # An event for container 1 being published: + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container1["id"]), + ), + }, + { # An event for the problem block in container 1: + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(problem_block["id"]), + ), + }, + { # An event for the html block in container 1 (and container 2): + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(html_block["id"]), + ), + }, + { # Not 100% sure we want this, but a PUBLISHED event is emitted for container 2 + # because one of its children's published versions has changed, so whether or + # not it contains unpublished changes may have changed and the search index + # may need to be updated. It is not actually published though. + # TODO: should this be a CONTAINER_CHILD_PUBLISHED event? + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container2["id"]), + ), + }, + ) + + # note that container 2 is still unpublished + c2_after = self._get_container(container2["id"]) + assert c2_after["has_unpublished_changes"] + + def test_restore_unit(self) -> None: + """ + Test restoring a deleted unit via the "restore" API. + """ + # Create a unit: + container_data = self._create_container(self.lib1_key, "unit", slug="u1", display_name="Test Unit") + container_key = LibraryContainerLocator.from_string(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + # Delete the unit + self._delete_container(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_DELETED, + "library_container": LibraryContainerData(container_key), + }) + + # Restore the unit + self._restore_container(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + def test_restore_unit_via_revert(self) -> None: + """ + Test restoring a deleted unit by reverting changes. + """ + # Publish the existing setup and clear events + self._commit_library_changes(self.lib1_key) + self.clear_events() + + # Create a unit: + container_data = self._create_container(self.lib1_key, "unit", slug="u1", display_name="Test Unit") + container_key = LibraryContainerLocator.from_string(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + # Publish changes + self._publish_container(container_key) + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData(container_key), + }) + + # Delete the unit + self._delete_container(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_DELETED, + "library_container": LibraryContainerData(container_key), + }) + + # Revert changes, which will re-create the unit: + self._revert_library_changes(self.lib1_key) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + ############################## Collections ################################## + + def test_collection_crud(self) -> None: + """ Test basic create, update, and delete events for collections """ + collection = self._create_collection(self.lib1_key, "Test Collection") + # To fix? The response from _create_collection should have the opaque key as the "id" field, not an integer. + collection_key = LibraryCollectionLocator(lib_key=self.lib1_key, collection_id=collection["key"]) + self.expect_new_events({ + "signal": LIBRARY_COLLECTION_CREATED, + "library_collection": LibraryCollectionData(collection_key), + }) + + # Update the collection: + self._update_collection(collection_key, description="Updated description") + self.expect_new_events({ + "signal": LIBRARY_COLLECTION_UPDATED, + "library_collection": LibraryCollectionData(collection_key), + }) + + # Soft delete the collection. NOTE: at the moment, it's only possible to "soft delete" collections via + # the REST API, which sends an UPDATED event because the collection is now "disabled" but not deleted. + self._soft_delete_collection(collection_key) + self.expect_new_events({ + "signal": LIBRARY_COLLECTION_UPDATED, # UPDATED not DELETED. If we do a hard delete, it should be DELETED. + "library_collection": LibraryCollectionData(collection_key), + }) + + # TODO: move more of the event-related collection tests from test_api.py to here, and convert them to use REST APIs diff --git a/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py b/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py index ad7ea54d8d..20d0b38f0b 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py @@ -2,7 +2,6 @@ Tests that several XBlock APIs support versioning """ from django.test.utils import override_settings -from openedx_events.tests.utils import OpenEdxEventsTestMixin from xblock.core import XBlock from openedx.core.djangoapps.content_libraries.tests.base import ( @@ -14,7 +13,7 @@ from .fields_test_block import FieldsTestBlock @skip_unless_cms @override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment? -class VersionedXBlockApisTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class VersionedXBlockApisTestCase(ContentLibrariesRestApiTest): """ Tests for three APIs implemented by djangoapps.xblock, and used by content libraries. These tests focus on versioning. diff --git a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py index 0f6dd85863..910eff2b0d 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py +++ b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py @@ -44,6 +44,12 @@ class TestCohortSignals(TestCase, OpenEdxEventsTestMixin): super().setUpClass() cls.start_events_isolation() + @classmethod + def tearDownClass(cls): + """ Don't let our event isolation affect other test cases """ + super().tearDownClass() + cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. + def setUp(self): super().setUp() self.course_key = CourseLocator("dummy", "dummy", "dummy") diff --git a/openedx/core/djangoapps/course_groups/tests/test_events.py b/openedx/core/djangoapps/course_groups/tests/test_events.py index 11ec0e3652..616a7bb3f1 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_events.py +++ b/openedx/core/djangoapps/course_groups/tests/test_events.py @@ -46,6 +46,12 @@ class CohortEventTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): super().setUpClass() cls.start_events_isolation() + @classmethod + def tearDownClass(cls): + """ Don't let our event isolation affect other test cases """ + super().tearDownClass() + cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. + def setUp(self): # pylint: disable=arguments-differ super().setUp() self.course = CourseOverviewFactory() diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index c43be8970d..ef5b3cc75a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -801,7 +801,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events==10.1.0 +openedx-events==10.2.0 # via # -r requirements/edx/kernel.in # edx-enterprise diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index cfdeac77a2..d95c1a6505 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1359,7 +1359,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==10.1.0 +openedx-events==10.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index d06d35b159..bcad7df56d 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -972,7 +972,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==10.1.0 +openedx-events==10.2.0 # via # -r requirements/edx/base.txt # edx-enterprise diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index ac52a56cc1..2707c6fd4f 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1031,7 +1031,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==10.1.0 +openedx-events==10.2.0 # via # -r requirements/edx/base.txt # edx-enterprise