diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 7d4da2a998c5..ede11dc5101a 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -1219,6 +1219,31 @@ def publish_changes(library_key, user_id=None): ) +def publish_component_changes(usage_key: LibraryUsageLocatorV2, user): + """ + Publish all pending changes in a single component. + """ + content_library = require_permission_for_library_key( + usage_key.lib_key, + user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY + ) + learning_package = content_library.learning_package + + assert learning_package + component = get_component_from_usage_key(usage_key) + drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter( + entity__key=component.key + ) + 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, + ) + ) + + def revert_changes(library_key): """ Revert all pending changes to the specified library, restoring it to the diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index cad6d37f5126..33d5477da698 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -27,6 +27,7 @@ URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library URL_LIB_PASTE_CLIPBOARD = URL_LIB_DETAIL + 'paste_clipboard/' # Paste user clipboard (POST) containing Xblock data URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/' # Get data about a block, or delete it +URL_LIB_BLOCK_PUBLISH = URL_LIB_BLOCK + 'publish/' # Publish changes from a specified XBlock URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specified XBlock URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file @@ -286,6 +287,10 @@ def _delete_library_block_asset(self, block_key, file_name, expect_response=204) url = URL_LIB_BLOCK_ASSET_FILE.format(block_key=block_key, file_name=file_name) return self._api('delete', url, None, expect_response) + def _publish_library_block(self, block_key, expect_response=200): + """ Publish changes from a specified XBlock """ + return self._api('post', URL_LIB_BLOCK_PUBLISH.format(block_key=block_key), None, expect_response) + def _paste_clipboard_content_in_library(self, lib_key, block_id, expect_response=200): """ Paste's the users clipboard content into Library """ url = URL_LIB_PASTE_CLIPBOARD.format(lib_key=lib_key) 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 04cf96abbf36..d8fc85f29de2 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -257,7 +257,7 @@ def test_library_filters(self): # General Content Library XBlock tests: - def test_library_blocks(self): + def test_library_blocks(self): # pylint: disable=too-many-statements """ Test the happy path of creating and working with XBlocks in a content library. @@ -359,6 +359,21 @@ def test_library_blocks(self): assert self._get_library(lib_id)['has_unpublished_deletes'] is False assert self._get_library_block_olx(block_id) == orig_olx + # Now edit and publish the single block instead of the whole library: + new_olx = "

Edited OLX

" + self._set_library_block_olx(block_id, new_olx) + assert self._get_library_block_olx(block_id) == new_olx + unpublished_block_data = self._get_library_block(block_id) + assert unpublished_block_data['has_unpublished_changes'] is True + block_update_date = datetime(2024, 8, 8, 8, 8, 9, tzinfo=timezone.utc) + with freeze_time(block_update_date): + self._publish_library_block(block_id) + # Confirm the block is now published: + published_block_data = self._get_library_block(block_id) + assert published_block_data['last_published'] == block_update_date.isoformat().replace('+00:00', 'Z') + assert published_block_data['published_by'] == "Bob" + assert published_block_data['has_unpublished_changes'] is False + # fin def test_library_blocks_studio_view(self): @@ -675,12 +690,13 @@ def test_library_permissions(self): # pylint: disable=too-many-statements # self._get_library_block_assets(block3_key) # self._get_library_block_asset(block3_key, file_name="whatever.png") - # Users without authoring permission cannot edit nor delete XBlocks: + # Users without authoring permission cannot edit nor publish nor delete XBlocks: for user in [reader, random_user]: with self.as_user(user): self._set_library_block_olx(block3_key, "", expect_response=403) self._set_library_block_fields(block3_key, {"data": "", "metadata": {}}, expect_response=403) self._set_library_block_asset(block3_key, "static/test.txt", b"data", expect_response=403) + self._publish_library_block(block3_key, expect_response=403) self._delete_library_block(block3_key, expect_response=403) self._commit_library_changes(lib_id, expect_response=403) self._revert_library_changes(lib_id, expect_response=403) @@ -694,9 +710,20 @@ def test_library_permissions(self): # pylint: disable=too-many-statements self._set_library_block_asset(block3_key, "static/test.txt", b"data") self._get_library_block_asset(block3_key, file_name="static/test.txt") self._delete_library_block(block3_key) + self._publish_library_block(block3_key) self._commit_library_changes(lib_id) self._revert_library_changes(lib_id) # This is a no-op after the commit, but should still have 200 response + # Users without authoring permission cannot commit Xblock changes: + # First we need to add some unpublished changes + with self.as_user(admin): + block4_data = self._add_block_to_library(lib_id, "problem", "problem4") + block5_data = self._add_block_to_library(lib_id, "problem", "problem5") + block4_key = block4_data["id"] + block5_key = block5_data["id"] + self._set_library_block_olx(block4_key, "") + self._set_library_block_olx(block5_key, "") + def test_no_lockout(self): """ Test that administrators cannot be removed if they are the only administrator granted access. diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index b9dc05fabc84..7806c75500a6 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -66,7 +66,8 @@ # CRUD for static asset files associated with a block in the library: path('assets/', views.LibraryBlockAssetListView.as_view()), path('assets/', views.LibraryBlockAssetView.as_view()), - # Future: publish/discard changes for just this one block + path('publish/', views.LibraryBlockPublishView.as_view()), + # Future: discard changes for just this one block # Future: set a block's tags (tags are stored in a Tag bundle and linked in) ])), re_path(r'^lti/1.3/', include([ diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index 75c75e70b822..585b08535d2c 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -822,6 +822,20 @@ def delete(self, request, usage_key_str, file_path): return Response(status=status.HTTP_204_NO_CONTENT) +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryBlockPublishView(APIView): + """ + Commit/publish all of the draft changes made to the component. + """ + + @convert_exceptions + def post(self, request, usage_key_str): + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.publish_component_changes(key, request.user) + return Response({}) + + @method_decorator(non_atomic_requests, name="dispatch") @view_auth_classes() class LibraryImportTaskViewSet(GenericViewSet): diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 79d4f45377dc..d608581b7f08 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -146,7 +146,7 @@ optimizely-sdk<5.0 # Date: 2023-09-18 # pinning this version to avoid updates while the library is being developed # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-learning==0.16.0 +openedx-learning==0.16.1 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index dedab26bcb93..239ee6c6f842 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -823,7 +823,7 @@ openedx-filters==1.11.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.16.0 +openedx-learning==0.16.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 40fd29ba8321..119d9138e37c 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1375,7 +1375,7 @@ openedx-filters==1.11.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.16.0 +openedx-learning==0.16.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 6686a11b7884..9b5bf5924b21 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -984,7 +984,7 @@ openedx-filters==1.11.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.16.0 +openedx-learning==0.16.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index debf11d1a782..7e4b80c26922 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1035,7 +1035,7 @@ openedx-filters==1.11.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.16.0 +openedx-learning==0.16.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt