diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py index fbcf2aca72f4..eec4dc11f38a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py @@ -53,6 +53,7 @@ class CourseDetailsSerializer(serializers.Serializer): pre_requisite_courses = serializers.ListField(child=CourseKeyField()) run = serializers.CharField() self_paced = serializers.BooleanField() + has_changes = serializers.BooleanField() short_description = serializers.CharField(allow_blank=True) start_date = serializers.DateTimeField() subtitle = serializers.CharField(allow_blank=True) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py index eb4f333e170a..5df4804027cb 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -125,7 +125,7 @@ class UpstreamLinkSerializer(serializers.Serializer): error_message = serializers.CharField(allow_null=True) ready_to_sync = serializers.BooleanField() downstream_customized = serializers.ListField(child=serializers.CharField(), allow_empty=True) - has_top_level_parent = serializers.BooleanField() + top_level_parent_key = serializers.CharField(allow_null=True) ready_to_sync_children = UpstreamChildrenInfoSerializer(many=True, required=False) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py index a7cf3a452627..cd1e1a99d074 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py @@ -323,7 +323,7 @@ def test_children_content(self): "version_declined": None, "error_message": None, "ready_to_sync": True, - "has_top_level_parent": False, + "top_level_parent_key": None, "downstream_customized": [], }, "user_partition_info": expected_user_partition_info, diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index f32d25a6a5b0..4a3b13f9c4ab 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -50,7 +50,7 @@ def _get_upstream_link_good_and_syncable(downstream): version_declined=downstream.upstream_version_declined, error_message=None, downstream_customized=[], - has_top_level_parent=False, + top_level_parent_key=None, upstream_name=downstream.upstream_display_name, ) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index ac9cc3d831ad..983471e1ebef 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -35,7 +35,7 @@ from olxcleaner.reporting import report_error_summary, report_errors from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey -from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocator, BlockUsageLocator +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocator from openedx_events.content_authoring.data import CourseData from openedx_events.content_authoring.signals import COURSE_RERUN_COMPLETED from organizations.api import add_organization_course, ensure_organization @@ -1641,11 +1641,7 @@ def handle_create_xblock_upstream_link(usage_key): return if xblock.top_level_downstream_parent_key is not None: block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key) - top_level_parent_usage_key = BlockUsageLocator( - xblock.course_id, - block_key.type, - block_key.id, - ) + top_level_parent_usage_key = block_key.to_usage_key(xblock.course_id) try: ContainerLink.get_by_downstream_usage_key(top_level_parent_usage_key) except ContainerLink.DoesNotExist: diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 5a5c380fcf58..f7c96d588a2e 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -1202,6 +1202,9 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements "edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None, + "edited_on_raw": str(xblock.subtree_edited_on) + if xblock.subtree_edited_on + else None, "published": published, "published_on": published_on, "studio_url": xblock_studio_url(xblock, parent_xblock), @@ -1331,7 +1334,7 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements # Disable adding or removing children component if xblock is imported from library xblock_actions["childAddable"] = False # Enable unlinking only for top level imported components - xblock_actions["unlinkable"] = not upstream_info["has_top_level_parent"] + xblock_actions["unlinkable"] = not upstream_info["top_level_parent_key"] if is_xblock_unit: # if xblock is a Unit we add the discussion_enabled option diff --git a/cms/lib/xblock/upstream_sync.py b/cms/lib/xblock/upstream_sync.py index 2b96e54c5ec9..fd3bb8a6733d 100644 --- a/cms/lib/xblock/upstream_sync.py +++ b/cms/lib/xblock/upstream_sync.py @@ -86,7 +86,7 @@ class UpstreamLink: version_declined: int | None # Latest version which the user has declined to sync with, if any. error_message: str | None # If link is valid, None. Otherwise, a localized, human-friendly error message. downstream_customized: list[str] | None # List of fields modified in downstream - has_top_level_parent: bool # True if this Upstream link has a top-level parent + top_level_parent_key: str | None # key of top-level parent if Upstream link has a one. @property def is_upstream_deleted(self) -> bool: @@ -153,7 +153,7 @@ def ready_to_sync(self) -> bool: from xmodule.modulestore.django import modulestore # If this component/container has top-level parent, so we need to sync the parent - if self.has_top_level_parent: + if self.top_level_parent_key: return False if isinstance(self.upstream_key, LibraryUsageLocatorV2): @@ -222,6 +222,10 @@ def try_get_for_block(cls, downstream: XBlock, log_error: bool = True) -> t.Self downstream.usage_key, downstream.upstream, ) + if top_level_parent_key := getattr(downstream, "top_level_downstream_parent_key", None): + top_level_parent_key = str( + BlockKey.from_string(top_level_parent_key).to_usage_key(downstream.usage_key.context_key) + ) return cls( upstream_ref=getattr(downstream, "upstream", None), upstream_name=getattr(downstream, "upstream_display_name", None), @@ -232,7 +236,7 @@ def try_get_for_block(cls, downstream: XBlock, log_error: bool = True) -> t.Self version_declined=None, error_message=str(exc), downstream_customized=getattr(downstream, "downstream_customized", []), - has_top_level_parent=getattr(downstream, "top_level_downstream_parent_key", None) is not None, + top_level_parent_key=top_level_parent_key, ) @classmethod @@ -306,6 +310,10 @@ def get_for_block(cls, downstream: XBlock) -> t.Self: ) ) + if top_level_parent_key := getattr(downstream, "top_level_downstream_parent_key", None): + top_level_parent_key = str( + BlockKey.from_string(top_level_parent_key).to_usage_key(downstream.usage_key.context_key) + ) result = cls( upstream_ref=downstream.upstream, upstream_key=upstream_key, @@ -316,7 +324,7 @@ def get_for_block(cls, downstream: XBlock) -> t.Self: version_declined=downstream.upstream_version_declined, error_message=None, downstream_customized=getattr(downstream, "downstream_customized", []), - has_top_level_parent=downstream.top_level_downstream_parent_key is not None, + top_level_parent_key=top_level_parent_key, ) return result diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index ae786f9ca1b6..573f00b7f867 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -25,7 +25,7 @@ block_is_unit = is_unit(xblock) upstream_info = UpstreamLink.try_get_for_block(xblock, log_error=False) -can_unlink = upstream_info.upstream_ref and not upstream_info.has_top_level_parent +can_unlink = upstream_info.upstream_ref and not upstream_info.top_level_parent_key %> <%namespace name='static' file='static_content.html'/> diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index c90081f30a03..95473adf21d4 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -77,6 +77,7 @@ def __init__(self, org, course_id, run): self.self_paced = None self.learning_info = [] self.instructor_info = [] + self.has_changes = None @classmethod def fetch_about_attribute(cls, course_key, attribute): @@ -127,6 +128,7 @@ def populate(cls, block): course_details.video_thumbnail_image_asset_path = course_image_url(block, 'video_thumbnail_image') course_details.language = block.language course_details.self_paced = block.self_paced + course_details.has_changes = modulestore().has_changes(block) course_details.learning_info = block.learning_info course_details.instructor_info = block.instructor_info course_details.title = block.display_name diff --git a/xmodule/util/keys.py b/xmodule/util/keys.py index 9570079200cc..4ca3a1c33eca 100644 --- a/xmodule/util/keys.py +++ b/xmodule/util/keys.py @@ -3,10 +3,11 @@ Consider moving these into opaque-keys if they generalize well. """ +from opaque_keys.edx.locator import BlockUsageLocator import hashlib from typing import NamedTuple, Self -from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.keys import UsageKey, CourseKey class BlockKey(NamedTuple): @@ -40,6 +41,16 @@ def from_string(cls, s: str) -> Self: raise ValueError(f"Invalid string format for BlockKey: {s}") return cls(parts[0], parts[1]) + def to_usage_key(self, course_key: CourseKey) -> UsageKey: + """ + Converts this BlockKey into a UsageKey. + """ + return BlockUsageLocator( + course_key, + self.type, + self.id, + ) + def derive_key(source: UsageKey, dest_parent: BlockKey) -> BlockKey: """