From b97007e1823320c3d97b5a84b0c988d888250e84 Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Mon, 31 Jul 2023 21:12:10 +0300 Subject: [PATCH] feat: Handle JSInput extra files when copying/pasting (#32847) This takes into account the extra files that are usually required when copying problems containing JSInputs. Static files such as additional CSS and JS files needed to interact and style the problem. --- common/test/data/uploads/simple-question.css | 11 +++ common/test/data/uploads/simple-question.html | 18 ++++ common/test/data/uploads/simple-question.js | 88 +++++++++++++++++++ .../lib/xblock_serializer/block_serializer.py | 4 + .../core/lib/xblock_serializer/test_api.py | 43 +++++++++ .../core/lib/xblock_serializer/test_utils.py | 59 +++++++++++++ openedx/core/lib/xblock_serializer/utils.py | 52 +++++++++++ 7 files changed, 275 insertions(+) create mode 100644 common/test/data/uploads/simple-question.css create mode 100644 common/test/data/uploads/simple-question.html create mode 100644 common/test/data/uploads/simple-question.js diff --git a/common/test/data/uploads/simple-question.css b/common/test/data/uploads/simple-question.css new file mode 100644 index 00000000000..2bf79257d50 --- /dev/null +++ b/common/test/data/uploads/simple-question.css @@ -0,0 +1,11 @@ +/* Original Source: https://files.edx.org/custom-js-example/jsinput_example.css */ + +.directions { + font-size: large +} + +.feedback { + font-size: medium; + border: 2px solid cornflowerblue; + padding: 5px; +} \ No newline at end of file diff --git a/common/test/data/uploads/simple-question.html b/common/test/data/uploads/simple-question.html new file mode 100644 index 00000000000..7aeb0978076 --- /dev/null +++ b/common/test/data/uploads/simple-question.html @@ -0,0 +1,18 @@ + + + + + + Simple Question + + + + + + + +

+ + diff --git a/common/test/data/uploads/simple-question.js b/common/test/data/uploads/simple-question.js new file mode 100644 index 00000000000..c214473470d --- /dev/null +++ b/common/test/data/uploads/simple-question.js @@ -0,0 +1,88 @@ +/* Original Source: https://files.edx.org/custom-js-example/jsinput_example.js */ + +/* globals Channel */ + +(function() { + 'use strict'; + + // state will be populated via initial_state via the `setState` method. Defining dummy values here + // to make the expected structure clear. + var state = { + availableChoices: [], + selectedChoice: '' + }, + channel, + select = document.getElementsByClassName('choices')[0], + feedback = document.getElementsByClassName('feedback')[0]; + + function populateSelect() { + // Populate the select from `state.availableChoices`. + var i, option; + + // Clear out any pre-existing options. + while (select.firstChild) { + select.removeChild(select.firstChild); + } + + // Populate the select with the available choices. + for (i = 0; i < state.availableChoices.length; i++) { + option = document.createElement('option'); + option.value = i; + option.innerHTML = state.availableChoices[i]; + if (state.availableChoices[i] === state.selectedChoice) { + option.selected = true; + } + select.appendChild(option); + } + feedback.innerText = "The currently selected answer is '" + state.selectedChoice + "'."; + } + + function getGrade() { + // The following return value may or may not be used to grade server-side. + // If getState and setState are used, then the Python grader also gets access + // to the return value of getState and can choose it instead to grade. + return JSON.stringify(state.selectedChoice); + } + + function getState() { + // Returns the current state (which can be used for grading). + return JSON.stringify(state); + } + + // This function will be called with 1 argument when JSChannel is not used, + // 2 otherwise. In the latter case, the first argument is a transaction + // object that will not be used here + // (see http://mozilla.github.io/jschannel/docs/) + function setState() { + var stateString = arguments.length === 1 ? arguments[0] : arguments[1]; + state = JSON.parse(stateString); + populateSelect(); + } + + // Establish a channel only if this application is embedded in an iframe. + // This will let the parent window communicate with this application using + // RPC and bypass SOP restrictions. + if (window.parent !== window) { + channel = Channel.build({ + window: window.parent, + origin: '*', + scope: 'JSInput' + }); + + channel.bind('getGrade', getGrade); + channel.bind('getState', getState); + channel.bind('setState', setState); + } + + select.addEventListener('change', function() { + state.selectedChoice = select.options[select.selectedIndex].text; + feedback.innerText = "You have selected '" + state.selectedChoice + + "'. Click Submit to grade your answer."; + }); + + return { + getState: getState, + setState: setState, + getGrade: getGrade + }; +}()); \ No newline at end of file diff --git a/openedx/core/lib/xblock_serializer/block_serializer.py b/openedx/core/lib/xblock_serializer/block_serializer.py index 9b6e970b114..84736a7762f 100644 --- a/openedx/core/lib/xblock_serializer/block_serializer.py +++ b/openedx/core/lib/xblock_serializer/block_serializer.py @@ -43,6 +43,10 @@ def __init__(self, block): if py_lib_zip_file: self.static_files.append(py_lib_zip_file) + js_input_files = utils.get_js_input_files_if_using(self.olx_str, course_key) + for js_input_file in js_input_files: + self.static_files.append(js_input_file) + def _serialize_block(self, block) -> etree.Element: """ Serialize an XBlock to OLX/XML. """ if block.scope_ids.usage_id.block_type == 'html': diff --git a/openedx/core/lib/xblock_serializer/test_api.py b/openedx/core/lib/xblock_serializer/test_api.py index 2867b4ae082..c589b9a9e32 100644 --- a/openedx/core/lib/xblock_serializer/test_api.py +++ b/openedx/core/lib/xblock_serializer/test_api.py @@ -244,3 +244,46 @@ def test_capa_python_lib(self): """ ) + + def test_jsinput_extra_files(self): + """ + Test JSInput problems with extra static files. + """ + course = CourseFactory.create(display_name='JSInput Testing course', run="JSI") + jsinput_files = [ + ("simple-question.html", "./common/test/data/uploads/simple-question.html"), + ("simple-question.js", "./common/test/data/uploads/simple-question.js"), + ("simple-question.css", "./common/test/data/uploads/simple-question.css"), + ("image.jpg", "./common/test/data/uploads/image.jpg"), + ("jschannel.js", "./common/static/js/capa/src/jschannel.js"), + ] + for filename, full_path in jsinput_files: + upload_file_to_course( + course_key=course.id, + contentstore=contentstore(), + source_file=full_path, + target_filename=filename, + ) + + jsinput_problem = BlockFactory.create( + parent_location=course.location, + category="problem", + display_name="JSInput Problem", + data="", + ) + + # The jsinput problem should contain the html_file along with extra static files: + + serialized = api.serialize_xblock_to_olx(jsinput_problem) + assert len(serialized.static_files) == 5 + for file in serialized.static_files: + self.assertIn(file.name, list(map(lambda f: f[0], jsinput_files))) + + self.assertXmlEqual( + serialized.olx_str, + """ + + + + """ + ) diff --git a/openedx/core/lib/xblock_serializer/test_utils.py b/openedx/core/lib/xblock_serializer/test_utils.py index e86ce21eafc..7b011bf064a 100644 --- a/openedx/core/lib/xblock_serializer/test_utils.py +++ b/openedx/core/lib/xblock_serializer/test_utils.py @@ -1,6 +1,7 @@ """ Test the OLX serialization utils """ +from __future__ import annotations import unittest import ddt @@ -63,3 +64,61 @@ def test_has_python_script(self, olx: str, has_script: bool): Test the _has_python_script() helper """ assert utils._has_python_script(olx) == has_script # pylint: disable=protected-access + + @ddt.unpack + @ddt.data( + ('''''', "/static/question.html"), + ('''''', "/static/simple-question.html"), + ('''''', "/static/simple-question.html"), + ('''''', "/static/simple.question.html"), + ('''''', None), + ('''''', None), + ('''''', None), + ('''some url: /static/simple-question.html''', None), + ) + def test_extract_local_html_path(self, olx: str, local_html_path: str | None): + """ + Test the _extract_local_html_path() helper. Confirm that it correctly detects the + presence of a `/static/` url in the 'html_file` attribute of a `` tag. + """ + assert utils._extract_local_html_path(olx) == local_html_path # pylint: disable=protected-access + + def test_extract_static_assets(self): + """ + Test the _extract_static_assets() helper. Confirm that it correctly extracts all the + static assets that have relative paths present in the html file. + """ + html_file_content = """ + + + + Example Title + + + + +

This is a non-existent css file: fake.css

+ + + + + + + + + + + """ + expected = [ + "simple-question.css", + "jsChannel.js", + "/some/path/simple-question.min.js", + "other/path/simple-question.min.js", + "mario.png" + ] + assert utils._extract_static_assets(html_file_content) == expected # pylint: disable=protected-access diff --git a/openedx/core/lib/xblock_serializer/utils.py b/openedx/core/lib/xblock_serializer/utils.py index 75dea641fa8..2c736ae2998 100644 --- a/openedx/core/lib/xblock_serializer/utils.py +++ b/openedx/core/lib/xblock_serializer/utils.py @@ -131,6 +131,58 @@ def _has_python_script(olx: str) -> bool: return False +def get_js_input_files_if_using(olx: str, course_id: CourseKey) -> [StaticFile]: + """ + When a problem uses JSInput and references an html file uploaded to the course (i.e. uses /static/), + all the other related static asset files that it depends on should also be included. + """ + static_files = [] + html_file_fullpath = _extract_local_html_path(olx) + if html_file_fullpath: + html_filename = html_file_fullpath.split('/')[-1] + asset_key = StaticContent.get_asset_key_from_path(course_id, html_filename) + html_file_content = AssetManager.find(asset_key, throw_on_not_found=False) + if html_file_content: + static_assets = _extract_static_assets(str(html_file_content.data)) + for static_asset in static_assets: + url = '/' + str(StaticContent.compute_location(course_id, static_asset)) + static_files.append(StaticFile(name=static_asset, url=url, data=None)) + + return static_files + + +def _extract_static_assets(html_file_content_data: str) -> [str]: + """ + Extracts all the static assets with relative paths that are present in the html content + """ + # Regular expression that looks for URLs that are inside HTML tag + # attributes (src or href) with relative paths. + # The pattern looks for either src or href, followed by an equals sign + # and then captures everything until it finds the closing quote (single or double) + assets_re = r'\b(?:src|href)\s*=\s*(?![\'"]?(?:https?://))["\']([^\'"]*?\.[^\'"]*?)["\']' + + # Find all matches in the HTML code + matches = re.findall(assets_re, html_file_content_data) + + return matches + + +def _extract_local_html_path(olx: str) -> str | None: + """ + Check if the given OlX block string contains a `jsinput` tag and the `html_file` attribute + is referencing a file in `/static/`. If so, extract the relative path of the html file in the OLX + """ + if "\/static\/[^\"\']*\.html)\1' + matches = re.search(local_html_file_re, olx) + if matches: + return matches.group('url') # Output example: /static/question.html + + return None + + @contextmanager def override_export_fs(block): """