feat: Add unlinkable to xblock actions and update top_level_parent_key on unlink [FC-0097] (#37215)

- Adds the `unlinkable` action to the XBlock object sent to the frontend
- Updates the `top_level_parent_key` reference when unlinking containers. If you unlink a Section with Subsections and Units, this updates the `top_level_parent_key` for the Subsections to `None` (they are the top level now), and the `top_level_parent_key` for the Units to the corresponding parent Subsection.
This commit is contained in:
Rômulo Penido
2025-08-28 13:30:17 -03:00
committed by GitHub
parent 052b930ef5
commit 8085bf6be4
9 changed files with 169 additions and 33 deletions

View File

@@ -455,16 +455,39 @@ class DownstreamView(DeveloperErrorViewMixin, APIView):
Sever an XBlock's link to upstream content.
"""
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
affected_blocks: list[XBlock] = []
try:
sever_upstream_link(downstream)
# Try to get the upstream key before severing the link, so we can delete
# the corresponding ComponentLink or ContainerLink below.
try:
upstream_key = UpstreamLink.get_for_block(downstream).upstream_key
except NoUpstream:
# Even if we don't have an UpstreamLink, we still need to check
# if the block has the upstream key set, so we don't want to
# raise an exception here.
upstream_key = None
affected_blocks = sever_upstream_link(downstream)
# Remove the ComponentLink or ContainerLink, if it exists.
if upstream_key:
if isinstance(upstream_key, LibraryUsageLocatorV2):
ComponentLink.get_by_downstream_usage_key(downstream.usage_key).delete()
elif isinstance(upstream_key, LibraryContainerLocator):
ContainerLink.get_by_downstream_usage_key(downstream.usage_key).delete()
except NoUpstream:
logger.exception(
"Tried to DELETE upstream link of '%s', but it wasn't linked to anything in the first place. "
"Will do nothing. ",
usage_key_string,
)
else:
modulestore().update_item(downstream, request.user.id)
finally:
if affected_blocks:
# If we successfully severed the upstream link, then we need to update the affected blocks.
with modulestore().bulk_operations(downstream.usage_key.context_key):
for block in affected_blocks:
modulestore().update_item(block, request.user.id)
return Response(status=204)

View File

@@ -2,29 +2,29 @@
Unit tests for /api/contentstore/v2/downstreams/* JSON APIs.
"""
import json
import ddt
from datetime import datetime, timezone
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
import ddt
from django.conf import settings
from django.urls import reverse
from freezegun import freeze_time
from opaque_keys.edx.keys import ContainerKey, UsageKey
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from organizations.models import Organization
from cms.djangoapps.contentstore.helpers import StaticFileNotices
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers as xblock_view_handlers
from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_dict
from opaque_keys.edx.keys import ContainerKey, UsageKey
from opaque_keys.edx.locator import LibraryLocatorV2
from common.djangoapps.student.tests.factories import UserFactory
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
from common.djangoapps.student.auth import add_users
from common.djangoapps.student.roles import CourseStaffRole
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
from .. import downstreams as downstreams_views
@@ -42,7 +42,7 @@ URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to
def _get_upstream_link_good_and_syncable(downstream):
return UpstreamLink(
upstream_ref=downstream.upstream,
upstream_key=UsageKey.from_string(downstream.upstream),
upstream_key=LibraryUsageLocatorV2.from_string(downstream.upstream),
downstream_key=str(downstream.usage_key),
version_synced=downstream.upstream_version,
version_available=(downstream.upstream_version or 0) + 1,
@@ -433,6 +433,45 @@ class DeleteDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
assert response.status_code == 204
assert mock_sever.call_count == 1
def test_unlink_parent_should_update_children_top_level_parent(self):
"""
If we unlink a parent block, do all children get the new top-level parent?
"""
self.client.login(username="superuser", password="password")
all_downstreams = self.client.get(
"/api/contentstore/v2/downstreams/",
data={"course_id": str(self.course.id)},
)
assert all_downstreams.data["count"] == 11
response = self.call_api(self.top_level_downstream_chapter.usage_key)
assert response.status_code == 204
# Check that all children have their top_level_downstream_parent_key updated
subsection = modulestore().get_item(self.top_level_downstream_sequential.usage_key)
assert subsection.top_level_downstream_parent_key is None
unit = modulestore().get_item(self.top_level_downstream_unit_2.usage_key)
# The sequential is the top-level parent for the unit
assert unit.top_level_downstream_parent_key == {
"id": str(self.top_level_downstream_sequential.usage_key.block_id),
"type": str(self.top_level_downstream_sequential.usage_key.block_type),
}
video = modulestore().get_item(self.top_level_downstream_video_key)
# The sequential is the top-level parent for the video
assert video.top_level_downstream_parent_key == {
"id": str(self.top_level_downstream_sequential.usage_key.block_id),
"type": str(self.top_level_downstream_sequential.usage_key.block_type),
}
all_downstreams = self.client.get(
"/api/contentstore/v2/downstreams/",
data={"course_id": str(self.course.id)},
)
assert all_downstreams.data["count"] == 10
class _DownstreamSyncViewTestMixin(SharedErrorTestCases):
"""
@@ -562,7 +601,7 @@ class GetUpstreamViewTest(
SharedModuleStoreTestCase,
):
"""
Test that `GET /api/v2/contentstore/downstreams-all?...` returns list of links based on the provided filter.
Test that `GET /api/v2/contentstore/downstreams?...` returns list of links based on the provided filter.
"""
def call_api(