Skip to content

Commit 334c0fe

Browse files
feat: REST API to publish the changes to a container in a library (#36543)
* feat: REST API to publish the changes to a container * fix: trigger LIBRARY_CONTAINER_UPDATED when component published for components in containers. --------- Co-authored-by: Jillian Vogel <jill@opencraft.com>
1 parent a960cdf commit 334c0fe

File tree

7 files changed

+192
-16
lines changed

7 files changed

+192
-16
lines changed

openedx/core/djangoapps/content_libraries/api/blocks.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
from openedx.core.types import User as UserType
4949

5050
from ..models import ContentLibrary
51-
from ..permissions import CAN_EDIT_THIS_CONTENT_LIBRARY
5251
from .exceptions import (
5352
BlockLimitReachedError,
5453
ContentLibraryBlockNotFound,
@@ -67,7 +66,6 @@
6766
from .libraries import (
6867
library_collection_locator,
6968
library_component_usage_key,
70-
require_permission_for_library_key,
7169
PublishableItem,
7270
)
7371

@@ -891,18 +889,14 @@ def publish_component_changes(usage_key: LibraryUsageLocatorV2, user: UserType):
891889
"""
892890
Publish all pending changes in a single component.
893891
"""
894-
content_library = require_permission_for_library_key(
895-
usage_key.lib_key,
896-
user,
897-
CAN_EDIT_THIS_CONTENT_LIBRARY
898-
)
892+
component = get_component_from_usage_key(usage_key)
893+
library_key = usage_key.context_key
894+
content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined]
899895
learning_package = content_library.learning_package
900-
901896
assert learning_package
902-
component = get_component_from_usage_key(usage_key)
903-
drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter(
904-
entity__key=component.key
905-
)
897+
# The core publishing API is based on draft objects, so find the draft that corresponds to this component:
898+
drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter(entity__key=component.key)
899+
# Publish the component and update anything that needs to be updated (e.g. search index):
906900
authoring_api.publish_from_drafts(learning_package.id, draft_qset=drafts_to_publish, published_by=user.id)
907901
LIBRARY_BLOCK_UPDATED.send_event(
908902
library_block=LibraryBlockData(
@@ -911,6 +905,17 @@ def publish_component_changes(usage_key: LibraryUsageLocatorV2, user: UserType):
911905
)
912906
)
913907

908+
# For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger
909+
# container indexing asynchronously.
910+
affected_containers = get_containers_contains_component(usage_key)
911+
for container in affected_containers:
912+
LIBRARY_CONTAINER_UPDATED.send_event(
913+
library_container=LibraryContainerData(
914+
container_key=container.container_key,
915+
background=True,
916+
)
917+
)
918+
914919

915920
def _component_exists(usage_key: UsageKeyV2) -> bool:
916921
"""

openedx/core/djangoapps/content_libraries/api/containers.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,21 @@
66
from dataclasses import dataclass
77
from datetime import datetime
88
from enum import Enum
9+
import logging
910
from uuid import uuid4
1011

1112
from django.utils.text import slugify
1213
from opaque_keys.edx.keys import UsageKeyV2
1314
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
14-
from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData, LibraryContainerData
15+
from openedx_events.content_authoring.data import (
16+
ContentObjectChangedData,
17+
LibraryBlockData,
18+
LibraryCollectionData,
19+
LibraryContainerData,
20+
)
1521
from openedx_events.content_authoring.signals import (
1622
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
23+
LIBRARY_BLOCK_UPDATED,
1724
LIBRARY_COLLECTION_UPDATED,
1825
LIBRARY_CONTAINER_CREATED,
1926
LIBRARY_CONTAINER_DELETED,
@@ -27,7 +34,7 @@
2734

2835
from ..models import ContentLibrary
2936
from .exceptions import ContentLibraryContainerNotFound
30-
from .libraries import LibraryXBlockMetadata, PublishableItem
37+
from .libraries import LibraryXBlockMetadata, PublishableItem, library_component_usage_key
3138

3239
# The public API is only the following symbols:
3340
__all__ = [
@@ -46,8 +53,11 @@
4653
"restore_container",
4754
"update_container_children",
4855
"get_containers_contains_component",
56+
"publish_container_changes",
4957
]
5058

59+
log = logging.getLogger(__name__)
60+
5161

5262
class ContainerType(Enum):
5363
Unit = "unit"
@@ -400,3 +410,41 @@ def get_containers_contains_component(
400410
ContainerMetadata.from_container(usage_key.context_key, container)
401411
for container in containers
402412
]
413+
414+
415+
def publish_container_changes(container_key: LibraryContainerLocator, user_id: int | None) -> None:
416+
"""
417+
Publish all unpublished changes in a container and all its child
418+
containers/blocks.
419+
"""
420+
container = get_container_from_key(container_key)
421+
library_key = container_key.library_key
422+
content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined]
423+
learning_package = content_library.learning_package
424+
assert learning_package
425+
# The core publishing API is based on draft objects, so find the draft that corresponds to this container:
426+
drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter(entity__pk=container.pk)
427+
# Publish the container, which will also auto-publish any unpublished child components:
428+
publish_log = authoring_api.publish_from_drafts(
429+
learning_package.id,
430+
draft_qset=drafts_to_publish,
431+
published_by=user_id,
432+
)
433+
# Update anything that needs to be updated (e.g. search index):
434+
for record in publish_log.records.select_related("entity", "entity__container", "entity__component").all():
435+
if hasattr(record.entity, "component"):
436+
# This is a child component like an XBLock in a Unit that was published:
437+
usage_key = library_component_usage_key(library_key, record.entity.component)
438+
LIBRARY_BLOCK_UPDATED.send_event(
439+
library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key)
440+
)
441+
elif hasattr(record.entity, "container"):
442+
# This is a child container like a Unit, or is the same "container" we published above.
443+
LIBRARY_CONTAINER_UPDATED.send_event(
444+
library_container=LibraryContainerData(container_key=container_key)
445+
)
446+
else:
447+
log.warning(
448+
f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation "
449+
"but is of unknown type."
450+
)

openedx/core/djangoapps/content_libraries/rest_api/blocks.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,15 @@ class LibraryBlockPublishView(APIView):
233233

234234
@convert_exceptions
235235
def post(self, request, usage_key_str):
236+
"""
237+
Publish the draft changes made to this component.
238+
"""
236239
key = LibraryUsageLocatorV2.from_string(usage_key_str)
240+
api.require_permission_for_library_key(
241+
key.lib_key,
242+
request.user,
243+
permissions.CAN_EDIT_THIS_CONTENT_LIBRARY
244+
)
237245
api.publish_component_changes(key, request.user)
238246
return Response({})
239247

openedx/core/djangoapps/content_libraries/rest_api/containers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,25 @@ def patch(self, request: RestRequest, container_key: LibraryContainerLocator) ->
328328
)
329329

330330
return Response({'count': len(collection_keys)})
331+
332+
333+
@method_decorator(non_atomic_requests, name="dispatch")
334+
@view_auth_classes()
335+
class LibraryContainerPublishView(GenericAPIView):
336+
"""
337+
View to publish a container, or revert to last published.
338+
"""
339+
@convert_exceptions
340+
def post(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response:
341+
"""
342+
Publish the container and its children
343+
"""
344+
api.require_permission_for_library_key(
345+
container_key.library_key,
346+
request.user,
347+
permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
348+
)
349+
api.publish_container_changes(container_key, request.user.id)
350+
# If we need to in the future, we could return a list of all the child containers/components that were
351+
# auto-published as a result.
352+
return Response({})

openedx/core/djangoapps/content_libraries/tests/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
URL_LIB_CONTAINER_COMPONENTS = URL_LIB_CONTAINER + 'children/' # Get, add or delete a component in this container
3737
URL_LIB_CONTAINER_RESTORE = URL_LIB_CONTAINER + 'restore/' # Restore a deleted container
3838
URL_LIB_CONTAINER_COLLECTIONS = URL_LIB_CONTAINER + 'collections/' # Handle associated collections
39+
URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to the specified container + children
3940

4041
URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/'
4142
URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
@@ -455,3 +456,7 @@ def _patch_container_collections(
455456
{'collection_keys': collection_keys},
456457
expect_response
457458
)
459+
460+
def _publish_container(self, container_key, expect_response=200):
461+
""" Publish all changes in the specified container + children """
462+
return self._api('post', URL_LIB_CONTAINER_PUBLISH.format(container_key=container_key), None, expect_response)

openedx/core/djangoapps/content_libraries/tests/test_containers.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
import ddt
88
from freezegun import freeze_time
99

10-
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2
10+
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
1111
from openedx_events.content_authoring.data import LibraryContainerData
1212
from openedx_events.content_authoring.signals import (
13+
LIBRARY_BLOCK_UPDATED,
1314
LIBRARY_CONTAINER_CREATED,
1415
LIBRARY_CONTAINER_DELETED,
1516
LIBRARY_CONTAINER_UPDATED,
@@ -43,6 +44,7 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest):
4344
break any tests, but backwards-incompatible API changes will.
4445
"""
4546
ENABLED_OPENEDX_EVENTS = [
47+
LIBRARY_BLOCK_UPDATED.event_type,
4648
LIBRARY_CONTAINER_CREATED.event_type,
4749
LIBRARY_CONTAINER_DELETED.event_type,
4850
LIBRARY_CONTAINER_UPDATED.event_type,
@@ -413,3 +415,88 @@ def test_container_collections(self):
413415

414416
# Verify the collections
415417
assert unit_as_read['collections'] == [{"title": col1.title, "key": col1.key}]
418+
419+
def test_publish_container(self): # pylint: disable=too-many-statements
420+
"""
421+
Test that we can publish the changes to a specific container
422+
"""
423+
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
424+
425+
# Create two containers and add some components
426+
container1 = self._create_container(lib["id"], "unit", display_name="Alpha Unit", slug=None)
427+
container2 = self._create_container(lib["id"], "unit", display_name="Bravo Unit", slug=None)
428+
problem_block = self._add_block_to_library(lib["id"], "problem", "Problem1", can_stand_alone=False)
429+
html_block = self._add_block_to_library(lib["id"], "html", "Html1", can_stand_alone=False)
430+
html_block2 = self._add_block_to_library(lib["id"], "html", "Html2", can_stand_alone=False)
431+
self._add_container_components(container1["id"], children_ids=[problem_block["id"], html_block["id"]])
432+
self._add_container_components(container2["id"], children_ids=[html_block["id"], html_block2["id"]])
433+
# At first everything is unpublished:
434+
c1_before = self._get_container(container1["id"])
435+
assert c1_before["has_unpublished_changes"]
436+
c1_components_before = self._get_container_components(container1["id"])
437+
assert len(c1_components_before) == 2
438+
assert c1_components_before[0]["id"] == problem_block["id"]
439+
assert c1_components_before[0]["has_unpublished_changes"]
440+
assert c1_components_before[0]["published_by"] is None
441+
assert c1_components_before[1]["id"] == html_block["id"]
442+
assert c1_components_before[1]["has_unpublished_changes"]
443+
assert c1_components_before[1]["published_by"] is None
444+
c2_before = self._get_container(container2["id"])
445+
assert c2_before["has_unpublished_changes"]
446+
447+
# Set up event receivers after the initial mock data setup is complete:
448+
updated_container_receiver = mock.Mock()
449+
updated_block_receiver = mock.Mock()
450+
LIBRARY_CONTAINER_UPDATED.connect(updated_container_receiver)
451+
LIBRARY_BLOCK_UPDATED.connect(updated_block_receiver)
452+
453+
# Now publish only Container 1
454+
self._publish_container(container1["id"])
455+
456+
# Now it is published:
457+
c1_after = self._get_container(container1["id"])
458+
assert c1_after["has_unpublished_changes"] is False
459+
c1_components_after = self._get_container_components(container1["id"])
460+
assert len(c1_components_after) == 2
461+
assert c1_components_after[0]["id"] == problem_block["id"]
462+
assert c1_components_after[0]["has_unpublished_changes"] is False
463+
assert c1_components_after[0]["published_by"] == self.user.username
464+
assert c1_components_after[1]["id"] == html_block["id"]
465+
assert c1_components_after[1]["has_unpublished_changes"] is False
466+
assert c1_components_after[1]["published_by"] == self.user.username
467+
468+
# and container 2 is still unpublished, except for the shared HTML block that is also in container 1:
469+
c2_after = self._get_container(container2["id"])
470+
assert c2_after["has_unpublished_changes"]
471+
c2_components_after = self._get_container_components(container2["id"])
472+
assert len(c2_components_after) == 2
473+
assert c2_components_after[0]["id"] == html_block["id"]
474+
assert c2_components_after[0]["has_unpublished_changes"] is False # published since it's also in container 1
475+
assert c2_components_after[0]["published_by"] == self.user.username
476+
assert c2_components_after[1]["id"] == html_block2["id"]
477+
assert c2_components_after[1]["has_unpublished_changes"] # unaffected
478+
assert c2_components_after[1]["published_by"] is None
479+
480+
# Make sure that the right events were sent out.
481+
# First, there should be one container updated event:
482+
assert len(updated_container_receiver.call_args_list) == 1
483+
self.assertDictContainsSubset(
484+
{
485+
"signal": LIBRARY_CONTAINER_UPDATED,
486+
"library_container": LibraryContainerData(
487+
container_key=LibraryContainerLocator.from_string(container1["id"]),
488+
),
489+
},
490+
updated_container_receiver.call_args_list[0].kwargs,
491+
)
492+
493+
# Second, two XBlock updated events:
494+
assert len(updated_block_receiver.call_args_list) == 2
495+
updated_block_ids = set(
496+
call.kwargs["library_block"].usage_key for call in updated_block_receiver.call_args_list
497+
)
498+
assert updated_block_ids == {
499+
LibraryUsageLocatorV2.from_string(problem_block["id"]),
500+
LibraryUsageLocatorV2.from_string(html_block["id"]),
501+
}
502+
assert all(call.kwargs["signal"] == LIBRARY_BLOCK_UPDATED for call in updated_block_receiver.call_args_list)

openedx/core/djangoapps/content_libraries/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@
8484
path('restore/', containers.LibraryContainerRestore.as_view()),
8585
# Update collections for a given container
8686
path('collections/', containers.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'),
87-
# path('publish/', views.LibraryContainerPublishView.as_view()),
87+
# Publish a container (or reset to last published)
88+
path('publish/', containers.LibraryContainerPublishView.as_view()),
8889
])),
8990
re_path(r'^lti/1.3/', include([
9091
path('login/', libraries.LtiToolLoginView.as_view(), name='lti-login'),

0 commit comments

Comments
 (0)