-
Notifications
You must be signed in to change notification settings - Fork 4.3k
[FC-0009] Paste Components (OLX) into any Unit in Studio #31969
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
Changes from all commits
805a2a8
6e11e05
79dd95d
0bccadf
c85e189
fe539fe
8ce5ea8
2a9350c
a72fdd0
db4eb97
9f0dded
1bf9f06
4dd11cd
2bba746
d5429b1
5e06a2c
c4129ce
aaee378
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,20 +3,31 @@ | |
| """ | ||
|
|
||
| import urllib | ||
| from lxml import etree | ||
| from uuid import uuid4 | ||
|
|
||
| from django.http import HttpResponse | ||
| from django.utils.translation import gettext as _ | ||
| from opaque_keys.edx.keys import UsageKey | ||
| from opaque_keys.edx.locator import DefinitionLocator, LocalId | ||
| from xblock.core import XBlock | ||
| from xblock.fields import ScopeIds | ||
| from xblock.runtime import IdGenerator | ||
| from xmodule.modulestore.django import modulestore | ||
| from xmodule.tabs import StaticTab | ||
|
|
||
| from cms.djangoapps.contentstore.views.preview import _load_preview_block | ||
| from cms.djangoapps.models.settings.course_grading import CourseGradingModel | ||
| from common.djangoapps.student import auth | ||
| from common.djangoapps.student.roles import CourseCreatorRole, OrgContentCreatorRole | ||
| from openedx.core.toggles import ENTRANCE_EXAMS | ||
|
|
||
| try: | ||
| # Technically this is a django app plugin, so we should not error if it's not installed: | ||
| import openedx.core.djangoapps.content_staging.api as content_staging_api | ||
| except ImportError: | ||
| content_staging_api = None | ||
|
|
||
| from ..utils import reverse_course_url, reverse_library_url, reverse_usage_url | ||
|
|
||
| __all__ = ['event'] | ||
|
|
@@ -271,6 +282,76 @@ def create_xblock(parent_locator, user, category, display_name, boilerplate=None | |
| return created_block | ||
|
|
||
|
|
||
| class ImportIdGenerator(IdGenerator): | ||
| """ | ||
| Modulestore's IdGenerator doesn't work for importing single blocks as OLX, | ||
| so we implement our own | ||
| """ | ||
| def __init__(self, context_key): | ||
| super().__init__() | ||
| self.context_key = context_key | ||
|
|
||
| def create_aside(self, definition_id, usage_id, aside_type): | ||
| """ Generate a new aside key """ | ||
| raise NotImplementedError() | ||
|
|
||
| def create_usage(self, def_id) -> UsageKey: | ||
| """ Generate a new UsageKey for an XBlock """ | ||
| # Note: Split modulestore will detect this temporary ID and create a new block ID when the XBlock is saved. | ||
| return self.context_key.make_usage_key(def_id.block_type, LocalId()) | ||
|
|
||
| def create_definition(self, block_type, slug=None) -> DefinitionLocator: | ||
| """ Generate a new definition_id for an XBlock """ | ||
| # Note: Split modulestore will detect this temporary ID and create a new definition ID when the XBlock is saved. | ||
| return DefinitionLocator(block_type, LocalId(block_type)) | ||
|
|
||
|
|
||
| def import_staged_content_from_user_clipboard(parent_key: UsageKey, request): | ||
| """ | ||
| Import a block (and any children it has) from "staged" OLX. | ||
| Does not deal with permissions or REST stuff - do that before calling this. | ||
|
|
||
| Returns the newly created block on success or None if the clipboard is | ||
| empty. | ||
| """ | ||
| if not content_staging_api: | ||
| raise RuntimeError("The required content_staging app is not installed") | ||
| user_clipboard = content_staging_api.get_user_clipboard(request.user.id) | ||
| if not user_clipboard: | ||
| # Clipboard is empty or expired/error/loading | ||
| return None | ||
| block_type = user_clipboard.content.block_type | ||
| olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id) | ||
| node = etree.fromstring(olx_str) | ||
| store = modulestore() | ||
| with store.bulk_operations(parent_key.course_key): | ||
| parent_descriptor = store.get_item(parent_key) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A very big descriptor -> block renaming refactor is dropping shortly. Please make check that your naming aligns:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, not sure. What I found was that this thing is not a proper XBlock, and it doesn't have a full XBlock runtime, just a So in this case I think the language is clear and necessary but I am not sure it aligns with the changes in that PR. @Agrendalath can you advise?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ormsbee, @bradenmacdonald, we still use the "descriptor" term in some places. In this case, we are retrieving an "unbound" XBlock from the Modulestore.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great, thanks for the quick reply. That aligns with my understanding here :) |
||
| # Some blocks like drag-and-drop only work here with the full XBlock runtime loaded: | ||
| parent_xblock = _load_preview_block(request, parent_descriptor) | ||
| runtime = parent_xblock.runtime | ||
| # Generate the new ID: | ||
| id_generator = ImportIdGenerator(parent_key.context_key) | ||
| def_id = id_generator.create_definition(block_type, user_clipboard.source_usage_key.block_id) | ||
| usage_id = id_generator.create_usage(def_id) | ||
| keys = ScopeIds(None, block_type, def_id, usage_id) | ||
| # parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either | ||
| # one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores | ||
| # 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'. | ||
| # For children of this block, obviously only id_generator is used. | ||
| xblock_class = runtime.load_block_type(block_type) | ||
| temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator) | ||
|
Agrendalath marked this conversation as resolved.
Outdated
|
||
| if xblock_class.has_children and temp_xblock.children: | ||
|
Agrendalath marked this conversation as resolved.
Outdated
|
||
| raise NotImplementedError("We don't yet support pasting XBlocks with children") | ||
| temp_xblock.parent = parent_key | ||
| # Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin) | ||
| temp_xblock.copied_from_block = str(user_clipboard.source_usage_key) | ||
| # Save the XBlock into modulestore. We need to save the block and its parent for this to work: | ||
| new_xblock = store.update_item(temp_xblock, request.user.id, allow_not_found=True) | ||
| parent_xblock.children.append(new_xblock.location) | ||
| store.update_item(parent_xblock, request.user.id) | ||
| return new_xblock | ||
|
|
||
|
|
||
| def is_item_in_course_tree(item): | ||
| """ | ||
| Check that the item is in the course tree. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| """ | ||
| Test the import_staged_content_from_user_clipboard() method, which is used to | ||
| allow users to paste XBlocks that were copied using the staged_content/clipboard | ||
| APIs. | ||
| """ | ||
| from opaque_keys.edx.keys import UsageKey | ||
| from rest_framework.test import APIClient | ||
| from xmodule.modulestore.django import modulestore | ||
| from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase | ||
| from xmodule.modulestore.tests.factories import ToyCourseFactory | ||
|
|
||
| CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/" | ||
| XBLOCK_ENDPOINT = "/xblock/" | ||
|
|
||
|
|
||
| class ClipboardPasteTestCase(ModuleStoreTestCase): | ||
| """ | ||
| Test Clipboard Paste functionality | ||
| """ | ||
|
|
||
| def _setup_course(self): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: did you intentionally decide not to use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I copied this from the other TestCase, where only some of the tests need this setup, which is why it's not in a As for mixing assert styles, is there a preferred style for the codebase now? I honestly don't know which I "should" be using.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
No strong preference here. If you think we can have tests that won't need the course setup, then we can keep it as is.
I don't see any coding guideline for this, but there were 36 PRs like #26576 that replaced these asserts, so I believe that the pytest style is preferred.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, I converted this test suite to pytest style. |
||
| """ Set up the "Toy Course" and an APIClient for testing clipboard functionality. """ | ||
| # Setup: | ||
| course_key = ToyCourseFactory.create().id # See xmodule/modulestore/tests/sample_courses.py | ||
| client = APIClient() | ||
| client.login(username=self.user.username, password=self.user_password) | ||
| return (course_key, client) | ||
|
|
||
| def test_copy_and_paste_video(self): | ||
| """ | ||
| Test copying a video from the course, and pasting it into the same unit | ||
| """ | ||
| course_key, client = self._setup_course() | ||
|
|
||
| # Check how many blocks are in the vertical currently | ||
| parent_key = course_key.make_usage_key("vertical", "vertical_test") # This is the vertical that holds the video | ||
| orig_vertical = modulestore().get_item(parent_key) | ||
| assert len(orig_vertical.children) == 4 | ||
|
|
||
| # Copy the video | ||
| video_key = course_key.make_usage_key("video", "sample_video") | ||
| copy_response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json") | ||
| assert copy_response.status_code == 200 | ||
|
|
||
| # Paste the video | ||
| paste_response = client.post(XBLOCK_ENDPOINT, { | ||
| "parent_locator": str(parent_key), | ||
| "staged_content": "clipboard", | ||
| }, format="json") | ||
| assert paste_response.status_code == 200 | ||
| new_block_key = UsageKey.from_string(paste_response.json()["locator"]) | ||
|
|
||
| # Now there should be an extra block in the vertical: | ||
| updated_vertical = modulestore().get_item(parent_key) | ||
| assert len(updated_vertical.children) == 5 | ||
| assert updated_vertical.children[-1] == new_block_key | ||
| # And it should match the original: | ||
| orig_video = modulestore().get_item(video_key) | ||
| new_video = modulestore().get_item(new_block_key) | ||
| assert new_video.youtube_id_1_0 == orig_video.youtube_id_1_0 | ||
| # The new block should store a reference to where it was copied from | ||
| assert new_video.copied_from_block == str(video_key) | ||
Uh oh!
There was an error while loading. Please reload this page.