feat: Add top-level parent logic to Upstream/Dowstream links [FC-0097] (#37076)

- Adds the `top_level_parent_usage_key` to the `EntityLinkBase`
- This field is used to save the top-level parent of a component or container when it is imported into a course. Example: A unit with components imported into a course. The unit is the top-level parent of the components.
- Updates the `DownstreamListView` to return the top-level parents instead of downstream child, if this parent exists.
- Each time containers with children were synchronized, a new downstream block was created for each child instead of updating the existing one. This occurred because the `upstream_key` was incorrectly validated as an `Opaquekey` against a list of key strings. This was fixed by converting the `upstream_key` to a string before the verification. (see 34cd5a4781 and 29647831dc)
- Which edX user roles will this change impact?  "Course Author", "Developer".
This commit is contained in:
Chris Chávez
2025-08-14 12:36:30 -05:00
committed by GitHub
parent bff6404d0f
commit ec72dc7998
14 changed files with 1134 additions and 199 deletions

View File

@@ -25,7 +25,7 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
from xblock.exceptions import XBlockNotFoundError
from xblock.fields import Scope, String, Integer
from xblock.fields import Scope, String, Integer, Dict
from xblock.core import XBlockMixin, XBlock
if t.TYPE_CHECKING:
@@ -327,6 +327,17 @@ class UpstreamSyncMixin(XBlockMixin):
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
)
top_level_downstream_parent_key = Dict(
help=(
"The block key ('block_type@block_id') of the downstream block that is the top-level parent of "
"this block. This is present if the creation of this block is a consequence of "
"importing a container that has one or more levels of children. "
"This represents the parent (container) in the top level "
"at the moment of the import."
),
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
)
@classmethod
def get_customizable_fields(cls) -> dict[str, str | None]:
"""