From 709e588e127dc36031adb222b3c94a54ccdaf351 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, 3 Jun 2024 23:00:48 +0300 Subject: [PATCH 1/7] feat: [AXM-542] create xblock renderer --- lms/envs/common.py | 1 + openedx/features/offline_mode/apps.py | 14 +++ openedx/features/offline_mode/renderer.py | 141 ++++++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 openedx/features/offline_mode/apps.py create mode 100644 openedx/features/offline_mode/renderer.py diff --git a/lms/envs/common.py b/lms/envs/common.py index 7fbddadf388c..ff2c14f1c0ed 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3328,6 +3328,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx.features.discounts', 'openedx.features.effort_estimation', 'openedx.features.name_affirmation_api.apps.NameAffirmationApiConfig', + 'openedx.features.offline_mode', 'lms.djangoapps.experiments', diff --git a/openedx/features/offline_mode/apps.py b/openedx/features/offline_mode/apps.py new file mode 100644 index 000000000000..c504af09dd11 --- /dev/null +++ b/openedx/features/offline_mode/apps.py @@ -0,0 +1,14 @@ +""" +OfflineMode application configuration +""" + + +from django.apps import AppConfig + + +class OfflineModeConfig(AppConfig): + """ + Application Configuration for Offline Mode module. + """ + + name = 'openedx.features.offline_mode' diff --git a/openedx/features/offline_mode/renderer.py b/openedx/features/offline_mode/renderer.py new file mode 100644 index 000000000000..9de75e76f9a9 --- /dev/null +++ b/openedx/features/offline_mode/renderer.py @@ -0,0 +1,141 @@ +""" +This module contains the XBlockRenderer class, +which is responsible for rendering an XBlock HTML content from the LMS. +""" +import logging + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.sessions.backends.db import SessionStore +from django.http import HttpRequest + +from opaque_keys.edx.keys import CourseKey, UsageKey +from xmodule.modulestore.django import modulestore + +from common.djangoapps.edxmako.shortcuts import render_to_string +from lms.djangoapps.courseware.block_render import get_block_by_usage_id +from lms.djangoapps.courseware.views.views import get_optimization_flags_for_content + +from openedx.core.lib.courses import get_course_by_id +from openedx.features.course_experience.utils import dates_banner_should_display +from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url + +User = get_user_model() +log = logging.getLogger(__name__) + + +class XBlockRenderer: + """ + Renders an XBlock HTML content from the LMS. + + Since imports from LMS are used here, XBlockRenderer can be called only in the LMS runtime. + + :param usage_key_string: The string representation of the block UsageKey. + :param user: The user for whom the XBlock will be rendered. + """ + + SERVICE_USERNAME = 'offline_mode_worker' + + def __init__(self, usage_key_string, user=None, request=None): + self.usage_key = UsageKey.from_string(usage_key_string) + self.usage_key = self.usage_key.replace(course_key=modulestore().fill_in_run(self.usage_key.course_key)) + self.user = user or self.service_user + self.request = request or self.generate_request() + + @property + def service_user(self): + """ + Returns a valid user to be used as the service user. + """ + try: + return User.objects.get(username=self.SERVICE_USERNAME) + except User.DoesNotExist as e: + log.error(f'Service user with username {self.SERVICE_USERNAME} to render XBlock does not exist.') + raise e + + def generate_request(self): + """ + Generates a request object with the service user and a session. + """ + request = HttpRequest() + request.user = self.user + session = SessionStore() + session.create() + request.session = session + return request + + def render_xblock_from_lms(self): + """ + Returns a string representation of the HTML content of the XBlock as it appears in the LMS. + + Blocks renders without header, footer and navigation. + Blocks view like a for regular user without staff or superuser access. + """ + course_key = self.usage_key.course_key + + with modulestore().bulk_operations(course_key): + course = get_course_by_id(course_key) + block, _ = get_block_by_usage_id( + self.request, + str(course_key), + str(self.usage_key), + disable_staff_debug_info=True, + course=course, + will_recheck_access='1', + ) + + enable_completion_on_view_service = False + wrap_xblock_data = None + completion_service = block.runtime.service(block, 'completion') + if completion_service and completion_service.completion_tracking_enabled(): + if completion_service.blocks_to_mark_complete_on_view({block}): + enable_completion_on_view_service = True + wrap_xblock_data = { + 'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms() + } + + fragment = self.get_fragment(block, wrap_xblock_data) + optimization_flags = get_optimization_flags_for_content(block, fragment) + missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, self.user) + + context = { + 'fragment': fragment, + 'course': course, + 'block': block, + 'enable_completion_on_view_service': enable_completion_on_view_service, + 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), + 'missed_deadlines': missed_deadlines, + 'missed_gated_content': missed_gated_content, + 'has_ended': course.has_ended(), + 'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'), + 'disable_accordion': True, + 'allow_iframing': True, + 'disable_header': True, + 'disable_footer': True, + 'disable_window_wrap': True, + 'edx_notes_enabled': False, + 'staff_access': False, + 'on_courseware_page': True, + 'is_learning_mfe': False, + 'is_mobile_app': True, + 'render_course_wide_assets': True, + 'LANGUAGE_CODE': 'en', + + **optimization_flags, + } + return render_to_string('courseware/courseware-chromeless.html', context, namespace='main') + + @staticmethod + def get_fragment(block, wrap_xblock_data=None): + """ + Returns the HTML fragment of the XBlock. + """ + student_view_context = { + 'show_bookmark_button': '0', + 'show_title': '1', + 'hide_access_error_blocks': True, + 'is_mobile_app': True, + } + if wrap_xblock_data: + student_view_context['wrap_xblock_data'] = wrap_xblock_data + return block.render('student_view', context=student_view_context) From 0b6426456873e92e21607d476bc876c9f7a40231 Mon Sep 17 00:00:00 2001 From: Ivan Niedielnitsev <81557788+NiedielnitsevIvan@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:50:36 +0300 Subject: [PATCH 2/7] feat: [AXM-349] Implement media generation for problem xblock (#2568) * feat: [AXM-349] Implement media generation for problem xblock * feat: [AXM-349] refactor offline content generation * style: [AXM-349] fix style issues * refactor: [AXM-349] move MathJax to assets * fix: [AXM-349] fix wrong filename * refactor: [AXM-349] refactor generated file pathes and generating task launching * style: [AXM-349] remove unused import --- openedx/features/offline_mode/__init__.py | 0 .../offline_mode/assets_management.py | 162 ++++++++++++++++++ openedx/features/offline_mode/constants.py | 13 ++ .../features/offline_mode/html_manipulator.py | 90 ++++++++++ openedx/features/offline_mode/renderer.py | 2 +- .../static/offline_mode/js/bridge.js | 35 ++++ openedx/features/offline_mode/tasks.py | 36 ++++ openedx/features/offline_mode/utils.py | 72 ++++++++ 8 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 openedx/features/offline_mode/__init__.py create mode 100644 openedx/features/offline_mode/assets_management.py create mode 100644 openedx/features/offline_mode/constants.py create mode 100644 openedx/features/offline_mode/html_manipulator.py create mode 100644 openedx/features/offline_mode/static/offline_mode/js/bridge.js create mode 100644 openedx/features/offline_mode/tasks.py create mode 100644 openedx/features/offline_mode/utils.py diff --git a/openedx/features/offline_mode/__init__.py b/openedx/features/offline_mode/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/offline_mode/assets_management.py b/openedx/features/offline_mode/assets_management.py new file mode 100644 index 000000000000..46c6af06c518 --- /dev/null +++ b/openedx/features/offline_mode/assets_management.py @@ -0,0 +1,162 @@ +""" +This module contains utility functions for managing assets and files. +""" +import shutil +import logging +import os +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 +from xmodule.contentstore.content import StaticContent +from xmodule.exceptions import NotFoundError +from xmodule.modulestore.exceptions import ItemNotFoundError + +from .constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH + + +log = logging.getLogger(__name__) + + +def get_static_file_path(relative_path): + """ + Constructs the absolute path for a static file based on its relative path. + """ + base_path = settings.STATIC_ROOT + return os.path.join(base_path, relative_path) + + +def read_static_file(path): + """ + Reads the contents of a static file in binary mode. + """ + with open(path, 'rb') as file: + return file.read() + + +def save_asset_file(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: + xblock (XBlock): The XBlock instance + path (str): The path where the asset is located. + filename (str): The name of the file to be saved. + """ + if filename.endswith('djangojs.js'): + return + + try: + if '/' in filename: + static_path = get_static_file_path(filename) + content = read_static_file(static_path) + else: + asset_key = StaticContent.get_asset_key_from_path(xblock.location.course_key, path) + content = AssetManager.find(asset_key).data + except (ItemNotFoundError, NotFoundError): + 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)) + + +def remove_old_files(xblock): + """ + Removes the 'assets' directory, 'index.html', and 'offline_content.zip' files + in the specified base path directory. + Args: + (XBlock): The XBlock instance + """ + try: + base_path = block_storage_path(xblock) + assets_path = os.path.join(base_path, 'assets') + index_file_path = os.path.join(base_path, 'index.html') + offline_zip_path = os.path.join(base_path, f'{xblock.location.block_id}.zip') + + # Delete the 'assets' directory if it exists + if os.path.isdir(assets_path): + shutil.rmtree(assets_path) + log.info(f"Successfully deleted the directory: {assets_path}") + + # Delete the 'index.html' file if it exists + if default_storage.exists(index_file_path): + os.remove(index_file_path) + log.info(f"Successfully deleted the file: {index_file_path}") + + # Delete the 'offline_content.zip' file if it exists + if default_storage.exists(offline_zip_path): + os.remove(offline_zip_path) + log.info(f"Successfully deleted the file: {offline_zip_path}") + + except OSError as e: + log.error(f"Error occurred while deleting the files or directory: {e}") + + +def get_offline_block_content_path(xblock=None, usage_key=None): + """ + Checks whether 'offline_content.zip' file is present in the specified base path directory. + + Args: + xblock (XBlock): The XBlock instance + usage_key (UsageKey): The UsageKey of the XBlock + Returns: + bool: True if the file is present, False otherwise + """ + usage_key = usage_key or getattr(xblock, 'location', None) + base_path = block_storage_path(usage_key=usage_key) + offline_zip_path = os.path.join(base_path, f'{usage_key.block_id}.zip') + return offline_zip_path if default_storage.exists(offline_zip_path) else None + + +def block_storage_path(xblock=None, usage_key=None): + """ + Generates the base storage path for the given XBlock. + + The path is constructed based on the XBlock's location, which includes the organization, + course, block type, and block ID. + Args: + xblock (XBlock): The XBlock instance for which to generate the storage path. + usage_key (UsageKey): The UsageKey of the XBlock. + Returns: + 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 '' + + +def is_modified(xblock): + """ + Check if the xblock has been modified since the last time the offline content was generated. + + Args: + xblock (XBlock): The XBlock instance to check. + """ + file_path = os.path.join(block_storage_path(xblock), f'{xblock.location.block_id}.zip') + + try: + last_modified = default_storage.get_created_time(file_path) + except OSError: + return True + + return xblock.published_on > last_modified + + +def save_mathjax_to_xblock_assets(xblock): + """ + 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): + response = requests.get(MATHJAX_CDN_URL) + default_storage.save(file_path, ContentFile(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 new file mode 100644 index 000000000000..b6609a4ce520 --- /dev/null +++ b/openedx/features/offline_mode/constants.py @@ -0,0 +1,13 @@ +""" +Constants for offline mode app. +""" +import os + +from django.conf import settings + +MATHJAX_VERSION = '2.7.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'] +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 new file mode 100644 index 000000000000..f8fce464ae2e --- /dev/null +++ b/openedx/features/offline_mode/html_manipulator.py @@ -0,0 +1,90 @@ +""" +Module to prepare HTML content for offline use. +""" +import os +import re + +from bs4 import BeautifulSoup + +from django.conf import settings + +from .assets_management import save_asset_file, save_mathjax_to_xblock_assets +from .constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH + + +class HtmlManipulator: + """ + Class to prepare HTML content for offline use. + + Changes links to static files to paths to pre-generated static files for offline use. + """ + + def __init__(self, xblock, html_data): + self.html_data = html_data + self.xblock = xblock + + def _replace_mathjax_link(self): + """ + Replace MathJax CDN link with local path to MathJax.js file. + """ + save_mathjax_to_xblock_assets(self.xblock) + mathjax_pattern = re.compile(fr'src="{MATHJAX_CDN_URL}[^"]*"') + self.html_data = mathjax_pattern.sub(f'src="{MATHJAX_STATIC_PATH}"', self.html_data) + + def _replace_static_links(self): + """ + Replace static links with local links. + """ + static_links_pattern = os.path.join(settings.STATIC_URL, r'[\w./-]+') + pattern = re.compile(fr'{static_links_pattern}') + self.html_data = pattern.sub(self._replace_link, self.html_data) + + def _replace_link(self, match): + """ + Returns the local path of the asset file. + """ + link = match.group() + filename = link.split(settings.STATIC_URL)[-1] + save_asset_file(self.xblock, link, filename) + return f'assets/{filename}' + + @staticmethod + def _replace_iframe(soup): + """ + Replace iframe tags with anchor tags. + """ + for node in soup.find_all('iframe'): + replacement = soup.new_tag('p') + tag_a = soup.new_tag('a') + tag_a['href'] = node.get('src') + 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 9de75e76f9a9..2262492f6295 100644 --- a/openedx/features/offline_mode/renderer.py +++ b/openedx/features/offline_mode/renderer.py @@ -9,7 +9,7 @@ from django.contrib.sessions.backends.db import SessionStore from django.http import HttpRequest -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import UsageKey from xmodule.modulestore.django import modulestore from common.djangoapps.edxmako.shortcuts import render_to_string diff --git a/openedx/features/offline_mode/static/offline_mode/js/bridge.js b/openedx/features/offline_mode/static/offline_mode/js/bridge.js new file mode 100644 index 000000000000..03e6755f28cd --- /dev/null +++ b/openedx/features/offline_mode/static/offline_mode/js/bridge.js @@ -0,0 +1,35 @@ +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/tasks.py b/openedx/features/offline_mode/tasks.py new file mode 100644 index 000000000000..75e786f43ca4 --- /dev/null +++ b/openedx/features/offline_mode/tasks.py @@ -0,0 +1,36 @@ +""" +Tasks for offline mode feature. +""" +from celery import shared_task +from edx_django_utils.monitoring import set_code_owner_attribute +from opaque_keys.edx.keys import CourseKey, UsageKey + +from xmodule.modulestore.django import modulestore + +from .constants import OFFLINE_SUPPORTED_XBLOCKS +from .renderer import XBlockRenderer +from .utils import generate_offline_content + + +@shared_task +@set_code_owner_attribute +def generate_offline_content_for_course(course_id): + """ + Generates offline content for all supported XBlocks in the course. + """ + course_key = CourseKey.from_string(course_id) + for offline_supported_block_type in OFFLINE_SUPPORTED_XBLOCKS: + for xblock in modulestore().get_items(course_key, qualifiers={'category': offline_supported_block_type}): + html_data = XBlockRenderer(str(xblock.location)).render_xblock_from_lms() + generate_offline_content_for_block.apply_async([str(xblock.location), html_data]) + + +@shared_task +@set_code_owner_attribute +def generate_offline_content_for_block(block_id, html_data): + """ + Generates offline content for the specified block. + """ + block_usage_key = UsageKey.from_string(block_id) + xblock = modulestore().get_item(block_usage_key) + generate_offline_content(xblock, html_data) diff --git a/openedx/features/offline_mode/utils.py b/openedx/features/offline_mode/utils.py new file mode 100644 index 000000000000..772a2fdb9b6b --- /dev/null +++ b/openedx/features/offline_mode/utils.py @@ -0,0 +1,72 @@ +""" +Utility functions and classes for offline mode. +""" +import os +import logging + +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 + +from .assets_management import block_storage_path, remove_old_files, is_modified +from .html_manipulator import HtmlManipulator + +User = get_user_model() +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 + """ + if not is_modified(xblock): + return + + base_path = block_storage_path(xblock) + remove_old_files(xblock) + html_manipulator = HtmlManipulator(xblock, html_data) + 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') From 857edc4bc40c7b29f3f95a3662795382beb3a79a 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: Thu, 6 Jun 2024 19:12:21 +0300 Subject: [PATCH 3/7] feat: [AXM-355] add information about offline metadata to course blocks --- lms/djangoapps/courseware/views/views.py | 274 ++++++++++++----------- openedx/features/offline_mode/toggles.py | 23 ++ 2 files changed, 166 insertions(+), 131 deletions(-) create mode 100644 openedx/features/offline_mode/toggles.py diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 86ecbb412215..632c257dbdee 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -1537,139 +1537,151 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta Returns an HttpResponse with HTML content for the xBlock with the given usage_key. The returned HTML is a chromeless rendering of the xBlock (excluding content of the containing courseware). """ + # usage_key = UsageKey.from_string(usage_key_string) + # + # usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) + # course_key = usage_key.course_key + # + # # Gathering metrics to make performance measurements easier. + # set_custom_attributes_for_course_key(course_key) + # set_custom_attribute('usage_key', usage_key_string) + # set_custom_attribute('block_type', usage_key.block_type) + # + # requested_view = request.GET.get('view', 'student_view') + # if requested_view != 'student_view' and requested_view != 'public_view': # lint-amnesty, pylint: disable=consider-using-in + # return HttpResponseBadRequest( + # f"Rendering of the xblock view '{bleach.clean(requested_view, strip=True)}' is not supported." + # ) + # + # staff_access = has_access(request.user, 'staff', course_key) + # + # with modulestore().bulk_operations(course_key): + # # verify the user has access to the course, including enrollment check + # try: + # course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=check_if_enrolled) + # except CourseAccessRedirect: + # raise Http404("Course not found.") # lint-amnesty, pylint: disable=raise-missing-from + # + # # with course access now verified: + # # assume masquerading role, if applicable. + # # (if we did this *before* the course access check, then course staff + # # masquerading as learners would often be denied access, since course + # # staff are generally not enrolled, and viewing a course generally + # # requires enrollment.) + # _course_masquerade, request.user = setup_masquerade( + # request, + # course_key, + # staff_access, + # ) + # + # # Record user activity for tracking progress towards a user's course goals (for mobile app) + # UserActivity.record_user_activity( + # request.user, usage_key.course_key, request=request, only_if_mobile_app=True + # ) + # + # # get the block, which verifies whether the user has access to the block. + # recheck_access = request.GET.get('recheck_access') == '1' + # block, _ = get_block_by_usage_id( + # request, + # str(course_key), + # str(usage_key), + # disable_staff_debug_info=disable_staff_debug_info, + # course=course, + # will_recheck_access=recheck_access, + # ) + # + # student_view_context = request.GET.dict() + # student_view_context['show_bookmark_button'] = request.GET.get('show_bookmark_button', '0') == '1' + # student_view_context['show_title'] = request.GET.get('show_title', '1') == '1' + # + # is_learning_mfe = is_request_from_learning_mfe(request) + # # Right now, we only care about this in regards to the Learning MFE because it results + # # in a bad UX if we display blocks with access errors (repeated upgrade messaging). + # # If other use cases appear, consider removing the is_learning_mfe check or switching this + # # to be its own query parameter that can toggle the behavior. + # student_view_context['hide_access_error_blocks'] = is_learning_mfe and recheck_access + # is_mobile_app = is_request_from_mobile_app(request) + # student_view_context['is_mobile_app'] = is_mobile_app + # + # enable_completion_on_view_service = False + # completion_service = block.runtime.service(block, 'completion') + # if completion_service and completion_service.completion_tracking_enabled(): + # if completion_service.blocks_to_mark_complete_on_view({block}): + # enable_completion_on_view_service = True + # student_view_context['wrap_xblock_data'] = { + # 'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms() + # } + # + # missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user) + # + # # Some content gating happens only at the Sequence level (e.g. "has this + # # timed exam started?"). + # ancestor_sequence_block = enclosing_sequence_for_gating_checks(block) + # if ancestor_sequence_block: + # context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, course_key)} + # # If the SequenceModule feels that gating is necessary, redirect + # # there so we can have some kind of error message at any rate. + # if ancestor_sequence_block.descendants_are_gated(context): + # return redirect( + # reverse( + # 'render_xblock', + # kwargs={'usage_key_string': str(ancestor_sequence_block.location)} + # ) + # ) + # + # # For courses using an LTI provider managed by edx-exams: + # # Access to exam content is determined by edx-exams and passed to the LMS using a + # # JWT url param. There is no longer a need for exam gating or logic inside the + # # sequence block or its render call. descendants_are_gated shoule not return true + # # for these timed exams. Instead, sequences are assumed gated by default and we look for + # # an access token on the request to allow rendering to continue. + # if course.proctoring_provider == 'lti_external': + # seq_block = ancestor_sequence_block if ancestor_sequence_block else block + # if getattr(seq_block, 'is_time_limited', None): + # if not _check_sequence_exam_access(request, seq_block.location): + # return HttpResponseForbidden("Access to exam content is restricted") + # + # fragment = block.render(requested_view, context=student_view_context) + # optimization_flags = get_optimization_flags_for_content(block, fragment) + # + # context = { + # 'fragment': fragment, + # 'course': course, + # 'block': block, + # 'disable_accordion': True, + # 'allow_iframing': True, + # 'disable_header': True, + # 'disable_footer': True, + # 'disable_window_wrap': True, + # 'enable_completion_on_view_service': enable_completion_on_view_service, + # 'edx_notes_enabled': is_feature_enabled(course, request.user), + # 'staff_access': staff_access, + # 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), + # 'missed_deadlines': missed_deadlines, + # 'missed_gated_content': missed_gated_content, + # 'has_ended': course.has_ended(), + # 'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'), + # 'on_courseware_page': True, + # 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), + # 'is_learning_mfe': is_learning_mfe, + # 'is_mobile_app': is_mobile_app, + # 'render_course_wide_assets': True, + # + # **optimization_flags, + # } + from django.http import Http404, HttpResponse + from opaque_keys.edx.keys import CourseKey, UsageKey + from xmodule.modulestore.django import modulestore + from openedx.features.offline_mode.renderer import XBlockRenderer + from openedx.features.offline_mode.utils import generate_offline_content + + usage_key_string = 'block-v1:OpenedX+DemoX+DemoCourse+type@problem+block@c89f56c74a3a424dbffb665d4643b42f' usage_key = UsageKey.from_string(usage_key_string) + xblock = modulestore().get_item(usage_key) + html_data = XBlockRenderer(usage_key_string).render_xblock_from_lms() + generate_offline_content(xblock, html_data) - usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) - course_key = usage_key.course_key - - # Gathering metrics to make performance measurements easier. - set_custom_attributes_for_course_key(course_key) - set_custom_attribute('usage_key', usage_key_string) - set_custom_attribute('block_type', usage_key.block_type) - - requested_view = request.GET.get('view', 'student_view') - if requested_view != 'student_view' and requested_view != 'public_view': # lint-amnesty, pylint: disable=consider-using-in - return HttpResponseBadRequest( - f"Rendering of the xblock view '{bleach.clean(requested_view, strip=True)}' is not supported." - ) - - staff_access = has_access(request.user, 'staff', course_key) - - with modulestore().bulk_operations(course_key): - # verify the user has access to the course, including enrollment check - try: - course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=check_if_enrolled) - except CourseAccessRedirect: - raise Http404("Course not found.") # lint-amnesty, pylint: disable=raise-missing-from - - # with course access now verified: - # assume masquerading role, if applicable. - # (if we did this *before* the course access check, then course staff - # masquerading as learners would often be denied access, since course - # staff are generally not enrolled, and viewing a course generally - # requires enrollment.) - _course_masquerade, request.user = setup_masquerade( - request, - course_key, - staff_access, - ) - - # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity( - request.user, usage_key.course_key, request=request, only_if_mobile_app=True - ) - - # get the block, which verifies whether the user has access to the block. - recheck_access = request.GET.get('recheck_access') == '1' - block, _ = get_block_by_usage_id( - request, - str(course_key), - str(usage_key), - disable_staff_debug_info=disable_staff_debug_info, - course=course, - will_recheck_access=recheck_access, - ) - - student_view_context = request.GET.dict() - student_view_context['show_bookmark_button'] = request.GET.get('show_bookmark_button', '0') == '1' - student_view_context['show_title'] = request.GET.get('show_title', '1') == '1' - - is_learning_mfe = is_request_from_learning_mfe(request) - # Right now, we only care about this in regards to the Learning MFE because it results - # in a bad UX if we display blocks with access errors (repeated upgrade messaging). - # If other use cases appear, consider removing the is_learning_mfe check or switching this - # to be its own query parameter that can toggle the behavior. - student_view_context['hide_access_error_blocks'] = is_learning_mfe and recheck_access - is_mobile_app = is_request_from_mobile_app(request) - student_view_context['is_mobile_app'] = is_mobile_app - - enable_completion_on_view_service = False - completion_service = block.runtime.service(block, 'completion') - if completion_service and completion_service.completion_tracking_enabled(): - if completion_service.blocks_to_mark_complete_on_view({block}): - enable_completion_on_view_service = True - student_view_context['wrap_xblock_data'] = { - 'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms() - } - - missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user) - - # Some content gating happens only at the Sequence level (e.g. "has this - # timed exam started?"). - ancestor_sequence_block = enclosing_sequence_for_gating_checks(block) - if ancestor_sequence_block: - context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, course_key)} - # If the SequenceModule feels that gating is necessary, redirect - # there so we can have some kind of error message at any rate. - if ancestor_sequence_block.descendants_are_gated(context): - return redirect( - reverse( - 'render_xblock', - kwargs={'usage_key_string': str(ancestor_sequence_block.location)} - ) - ) - - # For courses using an LTI provider managed by edx-exams: - # Access to exam content is determined by edx-exams and passed to the LMS using a - # JWT url param. There is no longer a need for exam gating or logic inside the - # sequence block or its render call. descendants_are_gated shoule not return true - # for these timed exams. Instead, sequences are assumed gated by default and we look for - # an access token on the request to allow rendering to continue. - if course.proctoring_provider == 'lti_external': - seq_block = ancestor_sequence_block if ancestor_sequence_block else block - if getattr(seq_block, 'is_time_limited', None): - if not _check_sequence_exam_access(request, seq_block.location): - return HttpResponseForbidden("Access to exam content is restricted") - - fragment = block.render(requested_view, context=student_view_context) - optimization_flags = get_optimization_flags_for_content(block, fragment) - - context = { - 'fragment': fragment, - 'course': course, - 'block': block, - 'disable_accordion': True, - 'allow_iframing': True, - 'disable_header': True, - 'disable_footer': True, - 'disable_window_wrap': True, - 'enable_completion_on_view_service': enable_completion_on_view_service, - 'edx_notes_enabled': is_feature_enabled(course, request.user), - 'staff_access': staff_access, - 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), - 'missed_deadlines': missed_deadlines, - 'missed_gated_content': missed_gated_content, - 'has_ended': course.has_ended(), - 'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'), - 'on_courseware_page': True, - 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), - 'is_learning_mfe': is_learning_mfe, - 'is_mobile_app': is_mobile_app, - 'render_course_wide_assets': True, - - **optimization_flags, - } - return render_to_response('courseware/courseware-chromeless.html', context) + return HttpResponse(html_data) def get_optimization_flags_for_content(block, fragment): diff --git a/openedx/features/offline_mode/toggles.py b/openedx/features/offline_mode/toggles.py new file mode 100644 index 000000000000..3b12b70ecf63 --- /dev/null +++ b/openedx/features/offline_mode/toggles.py @@ -0,0 +1,23 @@ +""" +Feature toggles for the offline mode app. +""" +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + +WAFFLE_FLAG_NAMESPACE = 'offline_mode' + +# .. toggle_name: e +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: This feature toggle enables the offline mode course +# content generation for mobile devices. +# .. toggle_use_cases: opt_out, open_edx +# .. toggle_creation_date: 2024-06-06 +# .. toggle_target_removal_date: None +ENABLE_OFFLINE_MODE = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_offline_mode', __name__) + + +def is_offline_mode_enabled(course_key=None): + """ + Returns True if the offline mode is enabled for the course, False otherwise. + """ + return ENABLE_OFFLINE_MODE.is_enabled(course_key) From 58fbd5cf54d3ece380fe85a25b75d495e65d046a 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: Thu, 6 Jun 2024 19:13:58 +0300 Subject: [PATCH 4/7] feat: [AXM-355] added a mechanism for generating offline content of blocks when publishing courses --- .../contentstore/signals/handlers.py | 11 + lms/djangoapps/courseware/views/views.py | 274 +++++++++--------- .../mobile_api/course_info/views.py | 28 +- lms/urls.py | 4 + openedx/features/offline_mode/toggles.py | 2 +- openedx/features/offline_mode/urls.py | 10 + openedx/features/offline_mode/views.py | 48 +++ 7 files changed, 232 insertions(+), 145 deletions(-) create mode 100644 openedx/features/offline_mode/urls.py create mode 100644 openedx/features/offline_mode/views.py diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index d756424bccaa..08347dd9cd53 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -2,9 +2,11 @@ import logging +import requests from datetime import datetime, timezone from functools import wraps from typing import Optional +from urllib.parse import urljoin from django.conf import settings from django.core.cache import cache @@ -27,6 +29,7 @@ from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task from openedx.core.lib.gating import api as gating_api +from openedx.features.offline_mode.toggles import is_offline_mode_enabled from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import SignalHandler, modulestore from .signals import GRADING_POLICY_CHANGED @@ -34,6 +37,7 @@ log = logging.getLogger(__name__) GRADING_POLICY_COUNTDOWN_SECONDS = 3600 +LMS_OFFLINE_HANDLER_URL = '/offline_mode/handle_course_published' def locked(expiry_seconds, key): # lint-amnesty, pylint: disable=missing-function-docstring @@ -155,6 +159,13 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable= # Send to a signal for catalog info changes as well, but only once we know the transaction is committed. transaction.on_commit(lambda: emit_catalog_info_changed_signal(course_key)) + if is_offline_mode_enabled(course_key): + requests.post( + url=urljoin(settings.LMS_ROOT_URL, LMS_OFFLINE_HANDLER_URL), + data={'course_id': str(course_key)}, + ) + log.info('Sent course_published event to offline mode handler') + @receiver(SignalHandler.course_deleted) def listen_for_course_delete(sender, course_key, **kwargs): # pylint: disable=unused-argument diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 632c257dbdee..86ecbb412215 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -1537,151 +1537,139 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta Returns an HttpResponse with HTML content for the xBlock with the given usage_key. The returned HTML is a chromeless rendering of the xBlock (excluding content of the containing courseware). """ - # usage_key = UsageKey.from_string(usage_key_string) - # - # usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) - # course_key = usage_key.course_key - # - # # Gathering metrics to make performance measurements easier. - # set_custom_attributes_for_course_key(course_key) - # set_custom_attribute('usage_key', usage_key_string) - # set_custom_attribute('block_type', usage_key.block_type) - # - # requested_view = request.GET.get('view', 'student_view') - # if requested_view != 'student_view' and requested_view != 'public_view': # lint-amnesty, pylint: disable=consider-using-in - # return HttpResponseBadRequest( - # f"Rendering of the xblock view '{bleach.clean(requested_view, strip=True)}' is not supported." - # ) - # - # staff_access = has_access(request.user, 'staff', course_key) - # - # with modulestore().bulk_operations(course_key): - # # verify the user has access to the course, including enrollment check - # try: - # course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=check_if_enrolled) - # except CourseAccessRedirect: - # raise Http404("Course not found.") # lint-amnesty, pylint: disable=raise-missing-from - # - # # with course access now verified: - # # assume masquerading role, if applicable. - # # (if we did this *before* the course access check, then course staff - # # masquerading as learners would often be denied access, since course - # # staff are generally not enrolled, and viewing a course generally - # # requires enrollment.) - # _course_masquerade, request.user = setup_masquerade( - # request, - # course_key, - # staff_access, - # ) - # - # # Record user activity for tracking progress towards a user's course goals (for mobile app) - # UserActivity.record_user_activity( - # request.user, usage_key.course_key, request=request, only_if_mobile_app=True - # ) - # - # # get the block, which verifies whether the user has access to the block. - # recheck_access = request.GET.get('recheck_access') == '1' - # block, _ = get_block_by_usage_id( - # request, - # str(course_key), - # str(usage_key), - # disable_staff_debug_info=disable_staff_debug_info, - # course=course, - # will_recheck_access=recheck_access, - # ) - # - # student_view_context = request.GET.dict() - # student_view_context['show_bookmark_button'] = request.GET.get('show_bookmark_button', '0') == '1' - # student_view_context['show_title'] = request.GET.get('show_title', '1') == '1' - # - # is_learning_mfe = is_request_from_learning_mfe(request) - # # Right now, we only care about this in regards to the Learning MFE because it results - # # in a bad UX if we display blocks with access errors (repeated upgrade messaging). - # # If other use cases appear, consider removing the is_learning_mfe check or switching this - # # to be its own query parameter that can toggle the behavior. - # student_view_context['hide_access_error_blocks'] = is_learning_mfe and recheck_access - # is_mobile_app = is_request_from_mobile_app(request) - # student_view_context['is_mobile_app'] = is_mobile_app - # - # enable_completion_on_view_service = False - # completion_service = block.runtime.service(block, 'completion') - # if completion_service and completion_service.completion_tracking_enabled(): - # if completion_service.blocks_to_mark_complete_on_view({block}): - # enable_completion_on_view_service = True - # student_view_context['wrap_xblock_data'] = { - # 'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms() - # } - # - # missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user) - # - # # Some content gating happens only at the Sequence level (e.g. "has this - # # timed exam started?"). - # ancestor_sequence_block = enclosing_sequence_for_gating_checks(block) - # if ancestor_sequence_block: - # context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, course_key)} - # # If the SequenceModule feels that gating is necessary, redirect - # # there so we can have some kind of error message at any rate. - # if ancestor_sequence_block.descendants_are_gated(context): - # return redirect( - # reverse( - # 'render_xblock', - # kwargs={'usage_key_string': str(ancestor_sequence_block.location)} - # ) - # ) - # - # # For courses using an LTI provider managed by edx-exams: - # # Access to exam content is determined by edx-exams and passed to the LMS using a - # # JWT url param. There is no longer a need for exam gating or logic inside the - # # sequence block or its render call. descendants_are_gated shoule not return true - # # for these timed exams. Instead, sequences are assumed gated by default and we look for - # # an access token on the request to allow rendering to continue. - # if course.proctoring_provider == 'lti_external': - # seq_block = ancestor_sequence_block if ancestor_sequence_block else block - # if getattr(seq_block, 'is_time_limited', None): - # if not _check_sequence_exam_access(request, seq_block.location): - # return HttpResponseForbidden("Access to exam content is restricted") - # - # fragment = block.render(requested_view, context=student_view_context) - # optimization_flags = get_optimization_flags_for_content(block, fragment) - # - # context = { - # 'fragment': fragment, - # 'course': course, - # 'block': block, - # 'disable_accordion': True, - # 'allow_iframing': True, - # 'disable_header': True, - # 'disable_footer': True, - # 'disable_window_wrap': True, - # 'enable_completion_on_view_service': enable_completion_on_view_service, - # 'edx_notes_enabled': is_feature_enabled(course, request.user), - # 'staff_access': staff_access, - # 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), - # 'missed_deadlines': missed_deadlines, - # 'missed_gated_content': missed_gated_content, - # 'has_ended': course.has_ended(), - # 'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'), - # 'on_courseware_page': True, - # 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), - # 'is_learning_mfe': is_learning_mfe, - # 'is_mobile_app': is_mobile_app, - # 'render_course_wide_assets': True, - # - # **optimization_flags, - # } - from django.http import Http404, HttpResponse - from opaque_keys.edx.keys import CourseKey, UsageKey - from xmodule.modulestore.django import modulestore - from openedx.features.offline_mode.renderer import XBlockRenderer - from openedx.features.offline_mode.utils import generate_offline_content - - usage_key_string = 'block-v1:OpenedX+DemoX+DemoCourse+type@problem+block@c89f56c74a3a424dbffb665d4643b42f' usage_key = UsageKey.from_string(usage_key_string) - xblock = modulestore().get_item(usage_key) - html_data = XBlockRenderer(usage_key_string).render_xblock_from_lms() - generate_offline_content(xblock, html_data) - return HttpResponse(html_data) + usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) + course_key = usage_key.course_key + + # Gathering metrics to make performance measurements easier. + set_custom_attributes_for_course_key(course_key) + set_custom_attribute('usage_key', usage_key_string) + set_custom_attribute('block_type', usage_key.block_type) + + requested_view = request.GET.get('view', 'student_view') + if requested_view != 'student_view' and requested_view != 'public_view': # lint-amnesty, pylint: disable=consider-using-in + return HttpResponseBadRequest( + f"Rendering of the xblock view '{bleach.clean(requested_view, strip=True)}' is not supported." + ) + + staff_access = has_access(request.user, 'staff', course_key) + + with modulestore().bulk_operations(course_key): + # verify the user has access to the course, including enrollment check + try: + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=check_if_enrolled) + except CourseAccessRedirect: + raise Http404("Course not found.") # lint-amnesty, pylint: disable=raise-missing-from + + # with course access now verified: + # assume masquerading role, if applicable. + # (if we did this *before* the course access check, then course staff + # masquerading as learners would often be denied access, since course + # staff are generally not enrolled, and viewing a course generally + # requires enrollment.) + _course_masquerade, request.user = setup_masquerade( + request, + course_key, + staff_access, + ) + + # Record user activity for tracking progress towards a user's course goals (for mobile app) + UserActivity.record_user_activity( + request.user, usage_key.course_key, request=request, only_if_mobile_app=True + ) + + # get the block, which verifies whether the user has access to the block. + recheck_access = request.GET.get('recheck_access') == '1' + block, _ = get_block_by_usage_id( + request, + str(course_key), + str(usage_key), + disable_staff_debug_info=disable_staff_debug_info, + course=course, + will_recheck_access=recheck_access, + ) + + student_view_context = request.GET.dict() + student_view_context['show_bookmark_button'] = request.GET.get('show_bookmark_button', '0') == '1' + student_view_context['show_title'] = request.GET.get('show_title', '1') == '1' + + is_learning_mfe = is_request_from_learning_mfe(request) + # Right now, we only care about this in regards to the Learning MFE because it results + # in a bad UX if we display blocks with access errors (repeated upgrade messaging). + # If other use cases appear, consider removing the is_learning_mfe check or switching this + # to be its own query parameter that can toggle the behavior. + student_view_context['hide_access_error_blocks'] = is_learning_mfe and recheck_access + is_mobile_app = is_request_from_mobile_app(request) + student_view_context['is_mobile_app'] = is_mobile_app + + enable_completion_on_view_service = False + completion_service = block.runtime.service(block, 'completion') + if completion_service and completion_service.completion_tracking_enabled(): + if completion_service.blocks_to_mark_complete_on_view({block}): + enable_completion_on_view_service = True + student_view_context['wrap_xblock_data'] = { + 'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms() + } + + missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user) + + # Some content gating happens only at the Sequence level (e.g. "has this + # timed exam started?"). + ancestor_sequence_block = enclosing_sequence_for_gating_checks(block) + if ancestor_sequence_block: + context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, course_key)} + # If the SequenceModule feels that gating is necessary, redirect + # there so we can have some kind of error message at any rate. + if ancestor_sequence_block.descendants_are_gated(context): + return redirect( + reverse( + 'render_xblock', + kwargs={'usage_key_string': str(ancestor_sequence_block.location)} + ) + ) + + # For courses using an LTI provider managed by edx-exams: + # Access to exam content is determined by edx-exams and passed to the LMS using a + # JWT url param. There is no longer a need for exam gating or logic inside the + # sequence block or its render call. descendants_are_gated shoule not return true + # for these timed exams. Instead, sequences are assumed gated by default and we look for + # an access token on the request to allow rendering to continue. + if course.proctoring_provider == 'lti_external': + seq_block = ancestor_sequence_block if ancestor_sequence_block else block + if getattr(seq_block, 'is_time_limited', None): + if not _check_sequence_exam_access(request, seq_block.location): + return HttpResponseForbidden("Access to exam content is restricted") + + fragment = block.render(requested_view, context=student_view_context) + optimization_flags = get_optimization_flags_for_content(block, fragment) + + context = { + 'fragment': fragment, + 'course': course, + 'block': block, + 'disable_accordion': True, + 'allow_iframing': True, + 'disable_header': True, + 'disable_footer': True, + 'disable_window_wrap': True, + 'enable_completion_on_view_service': enable_completion_on_view_service, + 'edx_notes_enabled': is_feature_enabled(course, request.user), + 'staff_access': staff_access, + 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), + 'missed_deadlines': missed_deadlines, + 'missed_gated_content': missed_gated_content, + 'has_ended': course.has_ended(), + 'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'), + 'on_courseware_page': True, + 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), + 'is_learning_mfe': is_learning_mfe, + 'is_mobile_app': is_mobile_app, + 'render_course_wide_assets': True, + + **optimization_flags, + } + return render_to_response('courseware/courseware-chromeless.html', context) def get_optimization_flags_for_content(block, fragment): diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index 40d586839680..562cf97b288f 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -2,13 +2,16 @@ Views for course info API """ +import os import logging from typing import Dict, Optional, Union import django +from django.conf import settings from django.contrib.auth import get_user_model +from django.core.files.storage import default_storage from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import CourseKey, UsageKey from rest_framework import generics, status from rest_framework.response import Response from rest_framework.reverse import reverse @@ -31,6 +34,9 @@ from openedx.core.lib.api.view_utils import view_auth_classes from openedx.core.lib.xblock_utils import get_course_update_items from openedx.features.course_experience import ENABLE_COURSE_GOALS +from openedx.features.offline_mode.assets_management import get_offline_block_content_path +from openedx.features.offline_mode.toggles import is_offline_mode_enabled + from ..decorators import mobile_course_access, mobile_view User = get_user_model() @@ -369,6 +375,8 @@ def list(self, request, **kwargs): # pylint: disable=W0221 course_key, response.data['blocks'], ) + if api_version == 'v4' and is_offline_mode_enabled(course_key): + self._extend_block_info_with_offline_data(response.data['blocks']) course_info_context = { 'user': requested_user, @@ -426,3 +434,21 @@ def _extend_sequential_info_with_assignment_progress( } } ) + + @staticmethod + def _extend_block_info_with_offline_data(blocks_info_data: Dict[str, Dict]) -> None: + """ + Extends block info with offline download data. + + If offline content is available for the block, adds the offline download data to the block info. + """ + for block_id, block_info in blocks_info_data.items(): + if offline_content_path := get_offline_block_content_path(usage_key=UsageKey.from_string(block_id)): + file_url = os.path.join(settings.MEDIA_URL, offline_content_path) + block_info.update({ + 'offline_download': { + 'file_url': file_url, + 'last_modified': default_storage.get_created_time(offline_content_path), + 'file_size': default_storage.size(offline_content_path) + } + }) diff --git a/lms/urls.py b/lms/urls.py index 15e374dd8551..98e02398de38 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -1053,3 +1053,7 @@ urlpatterns += [ path('api/notifications/', include('openedx.core.djangoapps.notifications.urls')), ] + +urlpatterns += [ + path('offline_mode/', include('openedx.features.offline_mode.urls')), +] diff --git a/openedx/features/offline_mode/toggles.py b/openedx/features/offline_mode/toggles.py index 3b12b70ecf63..e76c5ce56803 100644 --- a/openedx/features/offline_mode/toggles.py +++ b/openedx/features/offline_mode/toggles.py @@ -5,7 +5,7 @@ WAFFLE_FLAG_NAMESPACE = 'offline_mode' -# .. toggle_name: e +# .. toggle_name: offline_mode.enable_offline_mode # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This feature toggle enables the offline mode course diff --git a/openedx/features/offline_mode/urls.py b/openedx/features/offline_mode/urls.py new file mode 100644 index 000000000000..f5178a424316 --- /dev/null +++ b/openedx/features/offline_mode/urls.py @@ -0,0 +1,10 @@ +""" +URLs for the offline_mode feature. +""" +from django.urls import path + +from .views import SudioCoursePublishedEventHandler + +urlpatterns = [ + path('handle_course_published', SudioCoursePublishedEventHandler.as_view(), name='handle_course_published'), +] diff --git a/openedx/features/offline_mode/views.py b/openedx/features/offline_mode/views.py new file mode 100644 index 000000000000..f98ee4f07efa --- /dev/null +++ b/openedx/features/offline_mode/views.py @@ -0,0 +1,48 @@ +""" +Views for the offline_mode app. +""" +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .tasks import generate_offline_content_for_course +from .toggles import is_offline_mode_enabled + + +class SudioCoursePublishedEventHandler(APIView): + """ + Handle the event of a course being published in Studio. + + This view is called by Studio when a course is published, + and it triggers the generation of offline content. + """ + + def post(self, request, *args, **kwargs): + """ + Trigger the generation of offline content task. + + Args: + request (Request): The incoming request object. + args: Additional positional arguments. + kwargs: Additional keyword arguments. + + Returns: + Response: The response object. + """ + + course_id = request.data.get('course_id') + if not course_id: + return Response( + data={'error': 'course_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + course_key = CourseKey.from_string(course_id) + if is_offline_mode_enabled(course_key): + generate_offline_content_for_course.apply_async(args=[course_id]) + return Response(status=status.HTTP_200_OK) + else: + return Response( + data={'error': 'Offline mode is not enabled for this course'}, + status=status.HTTP_400_BAD_REQUEST + ) From 759a82ad478727ef7ae3bc33a0ebfb3474bd7083 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: Fri, 7 Jun 2024 09:39:51 +0300 Subject: [PATCH 5/7] style: [AXM-355] fix indentation --- openedx/features/offline_mode/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/features/offline_mode/views.py b/openedx/features/offline_mode/views.py index f98ee4f07efa..111b69175770 100644 --- a/openedx/features/offline_mode/views.py +++ b/openedx/features/offline_mode/views.py @@ -44,5 +44,5 @@ def post(self, request, *args, **kwargs): else: return Response( data={'error': 'Offline mode is not enabled for this course'}, - status=status.HTTP_400_BAD_REQUEST - ) + status=status.HTTP_400_BAD_REQUEST, + ) From cac3d7c2def6cdd3ce51f605c7694bed72eb3e58 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 6/7] 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 From 9b14946259ba2cc88d64d94595516bd6808f63fc 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 20:11:42 +0300 Subject: [PATCH 7/7] feat: [AXM-343] Transfer offline download solution for HTML xblock --- .../features/offline_mode/assets_management.py | 16 +++++++--------- .../features/offline_mode/html_manipulator.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/openedx/features/offline_mode/assets_management.py b/openedx/features/offline_mode/assets_management.py index fea45a8e8480..e8b73e9213e6 100644 --- a/openedx/features/offline_mode/assets_management.py +++ b/openedx/features/offline_mode/assets_management.py @@ -48,22 +48,20 @@ def save_asset_file(temp_dir, xblock, path, filename): path (str): The path where the asset is located. filename (str): The name of the file to be saved. """ - if filename.endswith('djangojs.js'): - return - try: - if '/' in filename: + if filename.startswith('assets/'): + asset_filename = filename.split('/')[-1] + asset_key = StaticContent.get_asset_key_from_path(xblock.location.course_key, asset_filename) + content = AssetManager.find(asset_key).data + file_path = os.path.join(temp_dir, filename) + else: static_path = get_static_file_path(filename) content = read_static_file(static_path) - else: - asset_key = StaticContent.get_asset_key_from_path(xblock.location.course_key, path) - content = AssetManager.find(asset_key).data + file_path = os.path.join(temp_dir, 'assets', filename) except (ItemNotFoundError, NotFoundError): log.info(f"Asset not found: {filename}") else: - 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) diff --git a/openedx/features/offline_mode/html_manipulator.py b/openedx/features/offline_mode/html_manipulator.py index a0c519096e1a..fbbca2af6494 100644 --- a/openedx/features/offline_mode/html_manipulator.py +++ b/openedx/features/offline_mode/html_manipulator.py @@ -30,6 +30,7 @@ def process_html(self): Changes links to static files to paths to pre-generated static files for offline use. """ + self._replace_asset_links() self._replace_static_links() self._replace_mathjax_link() @@ -53,6 +54,22 @@ def _replace_static_links(self): pattern = re.compile(fr'{static_links_pattern}') self.html_data = pattern.sub(self._replace_link, self.html_data) + def _replace_asset_links(self): + """ + Replace static links with local links. + """ + pattern = re.compile(r'/assets/[\w./@:+-]+') + self.html_data = pattern.sub(self._replace_asset_link, self.html_data) + + def _replace_asset_link(self, match): + """ + Returns the local path of the asset file. + """ + link = match.group() + filename = link[1:] if link.startswith('/') else link # Remove the leading '/' + save_asset_file(self.temp_dir, self.xblock, link, filename) + return filename + def _replace_link(self, match): """ Returns the local path of the asset file.