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/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/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/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/assets_management.py b/openedx/features/offline_mode/assets_management.py index 46c6af06c518..e8b73e9213e6 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,34 +36,43 @@ 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. """ - 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: - base_path = block_storage_path(xblock) - file_path = os.path.join(base_path, 'assets', filename) - default_storage.save(file_path, ContentFile(content)) + 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 +136,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 +156,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..fbbca2af6494 100644 --- a/openedx/features/offline_mode/html_manipulator.py +++ b/openedx/features/offline_mode/html_manipulator.py @@ -19,15 +19,30 @@ 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_asset_links() + 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) @@ -39,13 +54,29 @@ 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. """ 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 +91,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/toggles.py b/openedx/features/offline_mode/toggles.py new file mode 100644 index 000000000000..e76c5ce56803 --- /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: offline_mode.enable_offline_mode +# .. 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) 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/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 diff --git a/openedx/features/offline_mode/views.py b/openedx/features/offline_mode/views.py new file mode 100644 index 000000000000..111b69175770 --- /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, + )