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/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/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/__init__.py b/openedx/features/offline_mode/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 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/assets_management.py b/openedx/features/offline_mode/assets_management.py new file mode 100644 index 000000000000..e8b73e9213e6 --- /dev/null +++ b/openedx/features/offline_mode/assets_management.py @@ -0,0 +1,172 @@ +""" +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.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(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. + """ + try: + 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) + file_path = os.path.join(temp_dir, 'assets', filename) + except (ItemNotFoundError, NotFoundError): + log.info(f"Asset not found: {filename}") + + else: + 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): + """ + 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)}/' 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(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(temp_dir, MATHJAX_STATIC_PATH) + if not os.path.exists(file_path): + response = requests.get(MATHJAX_CDN_URL) + 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 new file mode 100644 index 000000000000..401d84d0a271 --- /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 = ['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 new file mode 100644 index 000000000000..fbbca2af6494 --- /dev/null +++ b/openedx/features/offline_mode/html_manipulator.py @@ -0,0 +1,93 @@ +""" +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, 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.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) + + 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_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.temp_dir, 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) diff --git a/openedx/features/offline_mode/renderer.py b/openedx/features/offline_mode/renderer.py new file mode 100644 index 000000000000..bb62792172bc --- /dev/null +++ b/openedx/features/offline_mode/renderer.py @@ -0,0 +1,142 @@ +""" +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 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, + 'is_offline_content': 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) 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/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 new file mode 100644 index 000000000000..55ac7c93d161 --- /dev/null +++ b/openedx/features/offline_mode/utils.py @@ -0,0 +1,102 @@ +""" +Utility functions and classes for offline mode. +""" +import os +import logging +import shutil +from tempfile import mkdtemp + +from django.contrib.auth import get_user_model +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 generate_offline_content(xblock, html_data): + """ + Generates archive with XBlock content for offline mode. + + Args: + xblock (XBlock): The XBlock instance + 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) + 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() + + 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, + )