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