Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: inheritable authoring mixin callbacks for editing & duplication #33756

Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
536493f
refactor: inheritable studioeditableblock's callbacks for editing & d…
DanielVZ96 Nov 21, 2023
5901e25
refactor: remove hasattr and add editable studio to cms xblock modules
DanielVZ96 Nov 25, 2023
5c2dabb
fix: tests
DanielVZ96 Nov 25, 2023
3e9bb60
fix: library preview
DanielVZ96 Nov 25, 2023
42c2037
style: ran darker
DanielVZ96 Nov 25, 2023
b4a4c96
style: pylint fixes
DanielVZ96 Nov 25, 2023
aaa26bf
style: use *_args and **_kwargs
DanielVZ96 Nov 30, 2023
116b013
refactor: move load_services_for_studio back into
DanielVZ96 Nov 30, 2023
c5cb3f8
refactor: move editor_saved, post_editor_saved, and studio_post_dupli…
DanielVZ96 Nov 30, 2023
6199588
refactor: move duplication logic into authoringmixin
DanielVZ96 Nov 30, 2023
7f6924f
style: pylint and pep8 fixes
DanielVZ96 Nov 30, 2023
adeec3a
refactor: rename source_item to source_block
DanielVZ96 Nov 30, 2023
cd78eb3
refactor: remove redundant hasattrs due to inheritance
DanielVZ96 Dec 1, 2023
893ff58
Merge branch 'master' into dvz/studio-editable-block-callback-refactor
DanielVZ96 Dec 5, 2023
8ad064c
Merge branch 'master' into dvz/studio-editable-block-callback-refactor
DanielVZ96 Dec 5, 2023
e94fa52
Merge branch 'master' into dvz/studio-editable-block-callback-refactor
DanielVZ96 Dec 8, 2023
3cb2769
revert: remove studio duplicate hooks
DanielVZ96 Jul 7, 2024
5312b5e
Merge branch 'master' into dvz/studio-editable-block-callback-refactor
DanielVZ96 Jul 8, 2024
9c85399
style: fix pylint unused imports
DanielVZ96 Jul 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 11 additions & 69 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
import logging
from collections import defaultdict
from contextlib import contextmanager
from datetime import datetime, timezone
from uuid import uuid4
from datetime import datetime

from django.conf import settings
from django.core.exceptions import ValidationError
Expand All @@ -18,8 +17,6 @@
from lti_consumer.models import CourseAllowPIISharingInLTIFlag
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import LibraryLocator
from openedx_events.content_authoring.data import DuplicatedXBlockData
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
from milestones import api as milestones_api
from pytz import UTC
from xblock.fields import Scope
Expand Down Expand Up @@ -1027,77 +1024,22 @@ def duplicate_block(
Duplicate an existing xblock as a child of the supplied parent_usage_key. You can
optionally specify what usage key the new duplicate block will use via dest_usage_key.

If shallow is True, does not copy children. Otherwise, this function calls itself
recursively, and will set the is_child flag to True when dealing with recursed child
blocks.
If shallow is True, does not copy children.
"""
store = modulestore()
with store.bulk_operations(duplicate_source_usage_key.course_key):
source_item = store.get_item(duplicate_source_usage_key)
if not dest_usage_key:
# Change the blockID to be unique.
dest_usage_key = source_item.location.replace(name=uuid4().hex)

category = dest_usage_key.block_type

duplicate_metadata, asides_to_create = gather_block_attributes(
source_item, display_name=display_name, is_child=is_child,
return source_item.studio_duplicate(
parent_usage_key=parent_usage_key,
duplicate_source_usage_key=duplicate_source_usage_key,
user=user,
store=store,
dest_usage_key=dest_usage_key,
display_name=display_name,
shallow=shallow,
is_child=is_child,
)

dest_block = store.create_item(
user.id,
dest_usage_key.course_key,
dest_usage_key.block_type,
block_id=dest_usage_key.block_id,
definition_data=source_item.get_explicitly_set_fields_by_scope(Scope.content),
metadata=duplicate_metadata,
runtime=source_item.runtime,
asides=asides_to_create
)

children_handled = False

if hasattr(dest_block, 'studio_post_duplicate'):
# Allow an XBlock to do anything fancy it may need to when duplicated from another block.
# These blocks may handle their own children or parenting if needed. Let them return booleans to
# let us know if we need to handle these or not.
load_services_for_studio(dest_block.runtime, user)
children_handled = dest_block.studio_post_duplicate(store, source_item)

# Children are not automatically copied over (and not all xblocks have a 'children' attribute).
# Because DAGs are not fully supported, we need to actually duplicate each child as well.
if source_item.has_children and not shallow and not children_handled:
dest_block.children = dest_block.children or []
for child in source_item.children:
dupe = duplicate_block(dest_block.location, child, user=user, is_child=True)
if dupe not in dest_block.children: # _duplicate_block may add the child for us.
dest_block.children.append(dupe)
store.update_item(dest_block, user.id)

# pylint: disable=protected-access
if 'detached' not in source_item.runtime.load_block_type(category)._class_tags:
parent = store.get_item(parent_usage_key)
# If source was already a child of the parent, add duplicate immediately afterward.
# Otherwise, add child to end.
if source_item.location in parent.children:
source_index = parent.children.index(source_item.location)
parent.children.insert(source_index + 1, dest_block.location)
else:
parent.children.append(dest_block.location)
store.update_item(parent, user.id)

# .. event_implemented_name: XBLOCK_DUPLICATED
XBLOCK_DUPLICATED.send_event(
time=datetime.now(timezone.utc),
xblock_info=DuplicatedXBlockData(
usage_key=dest_block.location,
block_type=dest_block.location.block_type,
source_usage_key=duplicate_source_usage_key,
)
)

return dest_block.location


def update_from_source(*, source_block, destination_block, user_id):
"""
Expand Down
4 changes: 2 additions & 2 deletions cms/djangoapps/contentstore/views/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from opaque_keys.edx.keys import CourseKey
from web_fragments.fragment import Fragment

from cms.djangoapps.contentstore.utils import load_services_for_studio
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
from common.djangoapps.edxmako.shortcuts import render_to_string
from common.djangoapps.student.auth import (
Expand All @@ -27,7 +28,7 @@
)
from xmodule.modulestore.django import (
modulestore,
) # lint-amnesty, pylint: disable=wrong-import-order
)


from xmodule.x_module import (
Expand All @@ -46,7 +47,6 @@
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
handle_xblock,
create_xblock_info,
load_services_for_studio,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's an indirect import, should be going directly to contentstore.utils

get_block_info,
get_xblock,
delete_orphans,
Expand Down
5 changes: 2 additions & 3 deletions cms/djangoapps/contentstore/views/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,13 @@
from openedx.core.djangoapps.content_staging import api as content_staging_api
from openedx.core.djangoapps.content_tagging.api import get_content_tags
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError
from ..toggles import use_new_unit_page
from ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url, get_unit_url
from ..utils import get_lms_link_for_item, get_sibling_urls, load_services_for_studio, reverse_course_url, get_unit_url
from ..helpers import get_parent_xblock, is_unit, xblock_type_display_name
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
add_container_page_publishing_info,
create_xblock_info,
load_services_for_studio,
)

__all__ = [
Expand Down
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/views/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
wrap_xblock_aside
)

from ..utils import get_visibility_partition_info, StudioPermissionsService
from ..utils import StudioPermissionsService, get_visibility_partition_info
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from formatter

from .access import get_user_role
from .session_kv_store import SessionKeyValueStore

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,13 +302,10 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
old_metadata = own_metadata(xblock)
if old_content is None:
old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content)
if hasattr(xblock, "editor_saved"):
load_services_for_studio(xblock.runtime, user)
xblock.editor_saved(user, old_metadata, old_content)
load_services_for_studio(xblock.runtime, user)
xblock.editor_saved(user, old_metadata, old_content)
xblock_updated = modulestore().update_item(xblock, user.id)
if hasattr(xblock_updated, "post_editor_saved"):
load_services_for_studio(xblock_updated.runtime, user)
xblock_updated.post_editor_saved(user, old_metadata, old_content)
xblock_updated.post_editor_saved(user, old_metadata, old_content)
return xblock_updated


Expand Down
113 changes: 113 additions & 0 deletions cms/lib/xblock/authoring_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@


import logging
from datetime import datetime, timezone
from uuid import uuid4

from django.conf import settings
from web_fragments.fragment import Fragment
from xblock.core import XBlock, XBlockMixin
from xblock.fields import String, Scope
from openedx_events.content_authoring.data import DuplicatedXBlockData
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED


log = logging.getLogger(__name__)

Expand Down Expand Up @@ -51,3 +56,111 @@ def visibility_view(self, _context=None):
scope=Scope.settings,
enforce_type=True,
)

def editor_saved(self, user, old_metadata, old_content) -> None: # pylint: disable=unused-argument
"""
Called right *before* the block is written to the DB. Can be used, e.g., to modify fields before saving.

By default, is a no-op. Can be overriden in subclasses.
"""

def post_editor_saved(self, user, old_metadata, old_content) -> None: # pylint: disable=unused-argument
"""
Called right *after* the block is written to the DB. Can be used, e.g., to spin up followup tasks.

By default, is a no-op. Can be overriden in subclasses.
"""
Agrendalath marked this conversation as resolved.
Show resolved Hide resolved

def studio_duplicate(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function feels very much like runtime code to me. What's the rationale for putting it into an XBlock mixin rather than the runtime? It seems like the goal here is to make studio_duplicate "inheritable" but I'm not sure that's a good thing, nor does it seem to be used?

And what about duplicating in v2 content libraries - are we going to have a NewRuntimeAuthoringMixin that implements studio_duplicate and studio_post_duplicate for the new/blockstore/learning-core runtime?

I guess my assumption has been that methods on XBlocks or their mixins generally only interact with the core XBlock API, the runtime, and/or runtime services. Any code that's specific to modulestore should be abstracted behind a runtime method or a service. I know we haven't always followed this pattern consistently, but I thought we had been moving in that direction.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ Note: This is more a question than a request for changes. And probably @kdmccormick and @Agrendalath are the ones best positioned to answer :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradenmacdonald,

I guess my assumption has been that methods on XBlocks or their mixins generally only interact with the core XBlock API, the runtime, and/or runtime services. Any code that's specific to modulestore should be abstracted behind a runtime method or a service. I know we haven't always followed this pattern consistently, but I thought we had been moving in that direction.

Yeah, that makes sense to me. This PR is part of a bigger GitHub issue (#33640), which I haven't been tracking closely enough to answer this, though. @kdmccormick definitely has a better idea about the planned follow-ups.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point @bradenmacdonald . This does seem like runtime capability rather than a block capability. Until recently I didn't really grasp the distinction between those two :P

@DanielVZ96 and @Agrendalath , when I wrote up the issue for this refactoring, I thought it'd be pretty straightfoward and self-contained. In reality, it seems like it's wrapped up in the complexities of Content Libraries V2 and copy-paste. I should have warned you folks of that when you originally opened your draft PR; that's my bad. I need to spend the next couple of work days sorting out our plan for duplication, copy-paste, and import for library_content blocks, and then I'll come back with a recommendation of what to do here. Thanks for your responsiveness and patience so far.

self,
parent_usage_key,
duplicate_source_usage_key,
user,
store,
dest_usage_key=None,
display_name=None,
shallow=False,
is_child=False,
):
"""
Duplicate an existing xblock as a child of the supplied parent_usage_key. You can
optionally specify what usage key the new duplicate block will use via dest_usage_key.

If shallow is True, does not copy children.
"""
from cms.djangoapps.contentstore.utils import gather_block_attributes, load_services_for_studio

if not dest_usage_key:
# Change the blockID to be unique.
dest_usage_key = self.location.replace(name=uuid4().hex)

category = dest_usage_key.block_type

duplicate_metadata, asides_to_create = gather_block_attributes(
self,
display_name=display_name,
is_child=is_child,
)

dest_block = store.create_item(
user.id,
dest_usage_key.course_key,
dest_usage_key.block_type,
block_id=dest_usage_key.block_id,
definition_data=self.get_explicitly_set_fields_by_scope(Scope.content),
metadata=duplicate_metadata,
runtime=self.runtime,
asides=asides_to_create,
)

# Allow an XBlock to do anything fancy it may need to when duplicated from another block.
load_services_for_studio(self.runtime, user)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a quick note: we replaced dest_block.runtime with self.runtime. These runtimes are shared, so it's okay, but I wanted to make this explicit.

dest_block.studio_post_duplicate(self, store, user, shallow=shallow)
# pylint: disable=protected-access
if "detached" not in self.runtime.load_block_type(category)._class_tags:
parent = store.get_item(parent_usage_key)
# If source was already a child of the parent, add duplicate immediately afterward.
# Otherwise, add child to end.
if self.location in parent.children:
source_index = parent.children.index(self.location)
parent.children.insert(source_index + 1, dest_block.location)
else:
parent.children.append(dest_block.location)
store.update_item(parent, user.id)

# .. event_implemented_name: XBLOCK_DUPLICATED
XBLOCK_DUPLICATED.send_event(
time=datetime.now(timezone.utc),
xblock_info=DuplicatedXBlockData(
usage_key=dest_block.location,
block_type=dest_block.location.block_type,
source_usage_key=duplicate_source_usage_key,
),
)

return dest_block.location

def studio_post_duplicate(
self,
source_block,
store,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're changing the API of studio_post_duplicate, I would love to find a way to remove store, since it's specific to modulestore. The store parameter wasn't actually used prior to this PR.

Passing user also seems a bit redundant as the user is available via the user service, as an ID via self.scope_ids.user_id, and via self.runtime.user (non-standard but works in all Open edX runtimes).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i agree, i really wanted to not inject the store dependency but this was the only way i figured i could keep the exact same behavior/code as before. The API is very recent and I doubt anyone uses it considering it's also internal.

I'll wait until @kdmccormick comes back to see what we can do to improve this API.

user,
shallow: bool,
) -> None: # pylint: disable=unused-argument
"""
Called when after a block is duplicated. Can be used, e.g., for special handling of child duplication.

Children must always be handled. In case of inheritance it can be done by running this method with super().

By default, implements standard duplication logic.
"""
if not source_block.has_children or shallow:
return

self.children = self.children or []
for child in source_block.children:
child_block = store.get_item(child)
dupe = child_block.studio_duplicate(self.location, child, user=user, store=store, is_child=True)
if dupe not in self.children: # studio_duplicate may add the child for us.
self.children.append(dupe)
store.update_item(self, user.id)
8 changes: 6 additions & 2 deletions xmodule/library_content_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,12 @@ def children_are_syncing(self, request, suffix=''): # pylint: disable=unused-ar
is_updating = False
return Response(json.dumps(is_updating))

def studio_post_duplicate(self, store, source_block):
def studio_post_duplicate(
self,
source_block,
*_args,
**__kwargs,
) -> None:
"""
Used by the studio after basic duplication of a source block. We handle the children
ourselves, because we have to properly reference the library upstream and set the overrides.
Expand All @@ -596,7 +601,6 @@ def studio_post_duplicate(self, store, source_block):
"""
self._validate_sync_permissions()
self.get_tools(to_read_library_content=True).trigger_duplication(source_block=source_block, dest_block=self)
return True # Children have been handled.

def _validate_library_version(self, validation, lib_tools, version, library_key):
"""
Expand Down
32 changes: 2 additions & 30 deletions xmodule/studio_editable.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Mixin to support editing in Studio.
"""
from xblock.core import XBlock, XBlockMixin

from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW


Expand All @@ -12,6 +13,7 @@ class StudioEditableBlock(XBlockMixin):

This class is only intended to be used with an XBlock!
"""

has_author_view = True
DanielVZ96 marked this conversation as resolved.
Show resolved Hide resolved

def render_children(self, context, fragment, can_reorder=False, can_add=False):
Expand Down Expand Up @@ -49,36 +51,6 @@ def get_preview_view_name(block):
"""
return AUTHOR_VIEW if has_author_view(block) else STUDENT_VIEW

# Some parts of the code use getattr to dynamically check for the following three methods on subclasses.
# We'd like to refactor so that we can actually declare them here as overridable methods.
# For now, we leave them here as documentation.
# See https://github.com/openedx/edx-platform/issues/33715.
#
# def editor_saved(self, old_metadata, old_content) -> None: # pylint: disable=unused-argument
# """
# Called right *before* the block is written to the DB. Can be used, e.g., to modify fields before saving.
#
# By default, is a no-op. Can be overriden in subclasses.
# """
#
# def post_editor_saved(self, old_metadata, old_content) -> None: # pylint: disable=unused-argument
# """
# Called right *after* the block is written to the DB. Can be used, e.g., to spin up followup tasks.
#
# By default, is a no-op. Can be overriden in subclasses.
# """
#
# def studio_post_duplicate(self, dest_block) -> bool: # pylint: disable=unused-argument
# """
# Called when a the block is duplicated. Can be used, e.g., for special handling of child duplication.
#
# Returns 'True' if children have been handled and thus shouldn't be handled by the standard
# duplication logic.
#
# By default, is a no-op. Can be overriden in subclasses.
# """
# return False


def has_author_view(block):
"""
Expand Down
Loading