From a50ce23e5633278e6745758655c9fa61dc1d95ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Mon, 10 Jun 2024 16:10:03 +0300 Subject: [PATCH] refactor: [AXM-583] refactor offline content generation --- .../courseware/courseware-chromeless.html | 42 +++++++ .../offline_mode/assets_management.py | 32 +++-- openedx/features/offline_mode/constants.py | 2 +- .../features/offline_mode/html_manipulator.py | 48 +++----- openedx/features/offline_mode/renderer.py | 1 + .../static/offline_mode/js/bridge.js | 35 ------ openedx/features/offline_mode/utils.py | 110 +++++++++++------- 7 files changed, 153 insertions(+), 117 deletions(-) delete mode 100644 openedx/features/offline_mode/static/offline_mode/js/bridge.js diff --git a/lms/templates/courseware/courseware-chromeless.html b/lms/templates/courseware/courseware-chromeless.html index e8411c9e4217..f137f4a2a347 100644 --- a/lms/templates/courseware/courseware-chromeless.html +++ b/lms/templates/courseware/courseware-chromeless.html @@ -216,3 +216,45 @@ }()); % endif + +% if is_offline_content: + +% endif diff --git a/openedx/features/offline_mode/assets_management.py b/openedx/features/offline_mode/assets_management.py index 46c6af06c518..fea45a8e8480 100644 --- a/openedx/features/offline_mode/assets_management.py +++ b/openedx/features/offline_mode/assets_management.py @@ -7,7 +7,6 @@ import requests from django.conf import settings -from django.core.files.base import ContentFile from django.core.files.storage import default_storage from xmodule.assetstore.assetmgr import AssetManager @@ -37,13 +36,14 @@ def read_static_file(path): return file.read() -def save_asset_file(xblock, path, filename): +def save_asset_file(temp_dir, xblock, path, filename): """ Saves an asset file to the default storage. If the filename contains a '/', it reads the static file directly from the file system. Otherwise, it fetches the asset from the AssetManager. Args: + temp_dir (str): The temporary directory where the assets are stored. xblock (XBlock): The XBlock instance path (str): The path where the asset is located. filename (str): The name of the file to be saved. @@ -62,9 +62,19 @@ def save_asset_file(xblock, path, filename): log.info(f"Asset not found: {filename}") else: - base_path = block_storage_path(xblock) - file_path = os.path.join(base_path, 'assets', filename) - default_storage.save(file_path, ContentFile(content)) + assets_path = os.path.join(temp_dir, 'assets') + file_path = os.path.join(assets_path, filename) + create_subdirectories_for_asset(file_path) + with open(file_path, 'wb') as file: + file.write(content) + + +def create_subdirectories_for_asset(file_path): + out_dir_name = '/' + for dir_name in file_path.split('/')[:-1]: + out_dir_name = os.path.join(out_dir_name, dir_name) + if out_dir_name and not os.path.exists(out_dir_name): + os.mkdir(out_dir_name) def remove_old_files(xblock): @@ -128,7 +138,7 @@ def block_storage_path(xblock=None, usage_key=None): str: The constructed base storage path. """ loc = usage_key or getattr(xblock, 'location', None) - return f'{str(loc.course_key)}/{loc.block_id}/' if loc else '' + return f'{str(loc.course_key)}/' if loc else '' def is_modified(xblock): @@ -148,15 +158,17 @@ def is_modified(xblock): return xblock.published_on > last_modified -def save_mathjax_to_xblock_assets(xblock): +def save_mathjax_to_xblock_assets(temp_dir): """ Saves MathJax to the local static directory. If MathJax is not already saved, it fetches MathJax from the CDN and saves it to the local static directory. """ - file_path = os.path.join(block_storage_path(xblock), MATHJAX_STATIC_PATH) - if not default_storage.exists(file_path): + file_path = os.path.join(temp_dir, MATHJAX_STATIC_PATH) + if not os.path.exists(file_path): response = requests.get(MATHJAX_CDN_URL) - default_storage.save(file_path, ContentFile(response.content)) + with open(file_path, 'wb') as file: + file.write(response.content) + log.info(f"Successfully saved MathJax to {file_path}") diff --git a/openedx/features/offline_mode/constants.py b/openedx/features/offline_mode/constants.py index b6609a4ce520..401d84d0a271 100644 --- a/openedx/features/offline_mode/constants.py +++ b/openedx/features/offline_mode/constants.py @@ -9,5 +9,5 @@ MATHJAX_CDN_URL = f'https://cdn.jsdelivr.net/npm/mathjax@{MATHJAX_VERSION}/MathJax.js' MATHJAX_STATIC_PATH = os.path.join('assets', 'js', f'MathJax-{MATHJAX_VERSION}.js') -DEFAULT_OFFLINE_SUPPORTED_XBLOCKS = ['problem'] +DEFAULT_OFFLINE_SUPPORTED_XBLOCKS = ['html'] OFFLINE_SUPPORTED_XBLOCKS = getattr(settings, 'OFFLINE_SUPPORTED_XBLOCKS', DEFAULT_OFFLINE_SUPPORTED_XBLOCKS) diff --git a/openedx/features/offline_mode/html_manipulator.py b/openedx/features/offline_mode/html_manipulator.py index f8fce464ae2e..a0c519096e1a 100644 --- a/openedx/features/offline_mode/html_manipulator.py +++ b/openedx/features/offline_mode/html_manipulator.py @@ -19,15 +19,29 @@ class HtmlManipulator: Changes links to static files to paths to pre-generated static files for offline use. """ - def __init__(self, xblock, html_data): + def __init__(self, xblock, html_data, temp_dir): self.html_data = html_data self.xblock = xblock + self.temp_dir = temp_dir + + def process_html(self): + """ + Prepares HTML content for local usage. + + Changes links to static files to paths to pre-generated static files for offline use. + """ + self._replace_static_links() + self._replace_mathjax_link() + + soup = BeautifulSoup(self.html_data, 'html.parser') + self._replace_iframe(soup) + return str(soup) def _replace_mathjax_link(self): """ Replace MathJax CDN link with local path to MathJax.js file. """ - save_mathjax_to_xblock_assets(self.xblock) + save_mathjax_to_xblock_assets(self.temp_dir) mathjax_pattern = re.compile(fr'src="{MATHJAX_CDN_URL}[^"]*"') self.html_data = mathjax_pattern.sub(f'src="{MATHJAX_STATIC_PATH}"', self.html_data) @@ -45,7 +59,7 @@ def _replace_link(self, match): """ link = match.group() filename = link.split(settings.STATIC_URL)[-1] - save_asset_file(self.xblock, link, filename) + save_asset_file(self.temp_dir, self.xblock, link, filename) return f'assets/{filename}' @staticmethod @@ -60,31 +74,3 @@ def _replace_iframe(soup): tag_a.string = node.get('title', node.get('src')) replacement.append(tag_a) node.replace_with(replacement) - - @staticmethod - def _add_js_bridge(soup): - """ - Add JS bridge script to the HTML content. - """ - script_tag = soup.new_tag('script') - with open('openedx/features/offline_mode/static/offline_mode/js/bridge.js', 'r') as file: - script_tag.string = file.read() - if soup.body: - soup.body.append(script_tag) - else: - soup.append(script_tag) - return soup - - def process_html(self): - """ - Prepares HTML content for local use. - - Changes links to static files to paths to pre-generated static files for offline use. - """ - self._replace_static_links() - self._replace_mathjax_link() - - soup = BeautifulSoup(self.html_data, 'html.parser') - self._replace_iframe(soup) - self._add_js_bridge(soup) - return str(soup) diff --git a/openedx/features/offline_mode/renderer.py b/openedx/features/offline_mode/renderer.py index 2262492f6295..bb62792172bc 100644 --- a/openedx/features/offline_mode/renderer.py +++ b/openedx/features/offline_mode/renderer.py @@ -118,6 +118,7 @@ def render_xblock_from_lms(self): 'on_courseware_page': True, 'is_learning_mfe': False, 'is_mobile_app': True, + 'is_offline_content': True, 'render_course_wide_assets': True, 'LANGUAGE_CODE': 'en', diff --git a/openedx/features/offline_mode/static/offline_mode/js/bridge.js b/openedx/features/offline_mode/static/offline_mode/js/bridge.js deleted file mode 100644 index 03e6755f28cd..000000000000 --- a/openedx/features/offline_mode/static/offline_mode/js/bridge.js +++ /dev/null @@ -1,35 +0,0 @@ -function sendMessageToiOS(message) { - window?.webkit?.messageHandlers?.iOSBridge?.postMessage(message); -} - -function sendMessageToAndroid(message) { - window?.AndroidBridge?.postMessage(message); -} - -function receiveMessageFromiOS(message) { - console.log("Message received from iOS:", message); -} - -function receiveMessageFromAndroid(message) { - console.log("Message received from Android:", message); -} - -if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.iOSBridge) { - window.addEventListener("messageFromiOS", function (event) { - receiveMessageFromiOS(event.data); - }); -} -if (window.AndroidBridge) { - window.addEventListener("messageFromAndroid", function (event) { - receiveMessageFromAndroid(event.data); - }); -} -const originalAjax = $.ajax; -$.ajax = function (options) { - sendMessageToiOS(options); - sendMessageToiOS(JSON.stringify(options)); - sendMessageToAndroid(options); - sendMessageToAndroid(JSON.stringify(options)); - console.log(options, JSON.stringify(options)); - return originalAjax.call(this, options); -}; diff --git a/openedx/features/offline_mode/utils.py b/openedx/features/offline_mode/utils.py index 772a2fdb9b6b..55ac7c93d161 100644 --- a/openedx/features/offline_mode/utils.py +++ b/openedx/features/offline_mode/utils.py @@ -3,9 +3,10 @@ """ import os import logging +import shutil +from tempfile import mkdtemp from django.contrib.auth import get_user_model -from django.core.files.base import ContentFile from django.core.files.storage import default_storage from zipfile import ZipFile @@ -17,56 +18,85 @@ log = logging.getLogger(__name__) -def create_zip_file(base_path, file_name): - """ - Creates a zip file with the content of the base_path directory. - """ - zf = ZipFile(default_storage.path(base_path + file_name), 'w') - zf.write(default_storage.path(base_path + 'index.html'), 'index.html') - - def add_files_to_zip(zip_file, current_base_path, current_path_in_zip): - """ - Recursively adds files to the zip file. - """ - try: - directories, filenames = default_storage.listdir(current_base_path) - except OSError: - return - - for filename in filenames: - full_path = os.path.join(current_base_path, filename) - zip_file.write(full_path, os.path.join(current_path_in_zip, filename)) - - for directory in directories: - add_files_to_zip( - zip_file, - os.path.join(current_base_path, directory), - os.path.join(current_path_in_zip, directory) - ) - - add_files_to_zip(zf, default_storage.path(base_path + 'assets/'), 'assets') - zf.close() - log.info(f'Offline content for {file_name} has been generated.') - - def generate_offline_content(xblock, html_data): """ Generates archive with XBlock content for offline mode. Args: xblock (XBlock): The XBlock instance - html_data (str): The HTML data of the XBlock + html_data (str): The rendered HTML representation of the XBlock """ if not is_modified(xblock): return base_path = block_storage_path(xblock) remove_old_files(xblock) - html_manipulator = HtmlManipulator(xblock, html_data) + tmp_dir = mkdtemp() + + try: + save_xblock_html(tmp_dir, xblock, html_data) + create_zip_file(tmp_dir, base_path, f'{xblock.location.block_id}.zip') + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def save_xblock_html(tmp_dir, xblock, html_data): + """ + Saves the XBlock HTML content to a file. + + Generates the 'index.html' file with the HTML added to use it locally. + + Args: + tmp_dir (str): The temporary directory path to save the xblock content + xblock (XBlock): The XBlock instance + html_data (str): The rendered HTML representation of the XBlock + """ + html_manipulator = HtmlManipulator(xblock, html_data, tmp_dir) updated_html = html_manipulator.process_html() - default_storage.save( - os.path.join(base_path, 'index.html'), - ContentFile(updated_html), - ) - create_zip_file(base_path, f'{xblock.location.block_id}.zip') + with open(os.path.join(tmp_dir, 'index.html'), 'w') as file: + file.write(updated_html) + + +def create_zip_file(temp_dir, base_path, file_name): + """ + Creates a zip file with the content of the base_path directory. + + Args: + temp_dir (str): The temporary directory path where the content is stored + base_path (str): The base path directory to save the zip file + file_name (str): The name of the zip file + """ + if not os.path.exists(default_storage.path(base_path)): + os.makedirs(default_storage.path(base_path)) + + with ZipFile(default_storage.path(base_path + file_name), 'w') as zip_file: + zip_file.write(os.path.join(temp_dir, 'index.html'), 'index.html') + add_files_to_zip_recursively( + zip_file, + current_base_path=os.path.join(temp_dir, 'assets'), + current_path_in_zip='assets', + ) + log.info(f'Offline content for {file_name} has been generated.') + + +def add_files_to_zip_recursively(zip_file, current_base_path, current_path_in_zip): + """ + Recursively adds files to the zip file. + + Args: + zip_file (ZipFile): The zip file object + current_base_path (str): The current base path directory + current_path_in_zip (str): The current path in the zip file + """ + try: + for resource_path in os.listdir(current_base_path): + full_path = os.path.join(current_base_path, resource_path) + full_path_in_zip = os.path.join(current_path_in_zip, resource_path) + if os.path.isfile(full_path): + zip_file.write(full_path, full_path_in_zip) + else: + add_files_to_zip_recursively(zip_file, full_path, full_path_in_zip) + except OSError: + log.error(f'Error while reading the directory: {current_base_path}') + return