diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index 0a173863db13..62b4713a2db1 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -1,14 +1,14 @@ """ Views for course info API """ - import logging from typing import Dict, Optional, Union import django 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 +31,8 @@ from openedx.core.djangoapps.video_pipeline.config.waffle import DEPRECATE_YOUTUBE 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.offline_mode.assets_management import get_offline_block_content_path +from openedx.features.offline_mode.toggles import is_offline_mode_enabled from openedx.features.course_experience import ENABLE_COURSE_GOALS from ..decorators import mobile_course_access, mobile_view @@ -352,6 +354,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, @@ -410,6 +414,22 @@ 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)): + block_info.update({ + 'offline_download': { + 'file_url': default_storage.url(offline_content_path), + 'last_modified': default_storage.get_modified_time(offline_content_path), + 'file_size': default_storage.size(offline_content_path) + } + }) + @mobile_view() class CourseEnrollmentDetailsView(APIView): diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_views.py b/lms/djangoapps/mobile_api/tests/test_course_info_views.py index 56c020ec8fa3..cea4af945ee5 100644 --- a/lms/djangoapps/mobile_api/tests/test_course_info_views.py +++ b/lms/djangoapps/mobile_api/tests/test_course_info_views.py @@ -2,7 +2,7 @@ Tests for course_info """ from datetime import datetime, timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch import ddt from django.conf import settings @@ -20,7 +20,7 @@ from lms.djangoapps.course_api.blocks.tests.test_views import TestBlocksInCourseView from lms.djangoapps.mobile_api.course_info.views import BlocksInfoInCourseView from lms.djangoapps.mobile_api.testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin -from lms.djangoapps.mobile_api.utils import API_V1, API_V05 +from lms.djangoapps.mobile_api.utils import API_V05, API_V1, API_V2, API_V3, API_V4 from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.course_experience import ENABLE_COURSE_GOALS from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order @@ -430,6 +430,64 @@ def test_extend_sequential_info_with_assignment_progress_for_other_types(self, b for block_info in response.data['blocks'].values(): self.assertNotEqual('assignment_progress', block_info) + @patch('lms.djangoapps.mobile_api.course_info.views.default_storage') + @patch('lms.djangoapps.mobile_api.course_info.views.get_offline_block_content_path') + @patch('lms.djangoapps.mobile_api.course_info.views.is_offline_mode_enabled') + def test_extend_block_info_with_offline_data( + self, + is_offline_mode_enabled_mock: MagicMock, + get_offline_block_content_path_mock: MagicMock, + default_storage_mock: MagicMock, + ) -> None: + url = reverse('blocks_info_in_course', kwargs={'api_version': API_V4}) + offline_content_path_mock = '/offline_content_path_mock/' + created_time_mock = 'created_time_mock' + size_mock = 'size_mock' + get_offline_block_content_path_mock.return_value = offline_content_path_mock + default_storage_mock.get_modified_time.return_value = created_time_mock + default_storage_mock.size.return_value = size_mock + default_storage_mock.url.return_value = offline_content_path_mock + + expected_offline_download_data = { + 'file_url': offline_content_path_mock, + 'last_modified': created_time_mock, + 'file_size': size_mock + } + + response = self.verify_response(url=url) + + is_offline_mode_enabled_mock.assert_called_once_with(self.course.course_id) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for block_info in response.data['blocks'].values(): + self.assertDictEqual(block_info['offline_download'], expected_offline_download_data) + + @patch('lms.djangoapps.mobile_api.course_info.views.is_offline_mode_enabled') + @ddt.data( + (API_V05, True), + (API_V05, False), + (API_V1, True), + (API_V1, False), + (API_V2, True), + (API_V2, False), + (API_V3, True), + (API_V3, False), + ) + @ddt.unpack + def test_not_extend_block_info_with_offline_data_for_version_less_v4_and_any_waffle_flag( + self, + api_version: str, + offline_mode_waffle_flag_mock: MagicMock, + is_offline_mode_enabled_mock: MagicMock, + ) -> None: + url = reverse('blocks_info_in_course', kwargs={'api_version': api_version}) + is_offline_mode_enabled_mock.return_value = offline_mode_waffle_flag_mock + + response = self.verify_response(url=url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for block_info in response.data['blocks'].values(): + self.assertNotIn('offline_download', block_info) + class TestCourseEnrollmentDetailsView(MobileAPITestCase, MilestonesTestCaseMixin): # lint-amnesty, pylint: disable=test-inherits-tests """ diff --git a/lms/envs/common.py b/lms/envs/common.py index b6ac23343d58..066fdb20d864 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3312,6 +3312,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.apps.OfflineModeConfig', 'lms.djangoapps.experiments', @@ -5648,3 +5649,8 @@ def _should_send_learning_badge_events(settings): # .. toggle_creation_date: 2024-11-10 # .. toggle_target_removal_date: 2025-06-01 USE_EXTRACTED_VIDEO_BLOCK = False + +# .. setting_name: RETIREMENT_SERVICE_WORKER_USERNAME +# .. setting_default: offline_mode_worker +# .. setting_description: Set the username for generating offline content. The user is used for rendering blocks. +OFFLINE_SERVICE_WORKER_USERNAME = "offline_mode_worker" diff --git a/lms/static/js/courseware/bridge.js b/lms/static/js/courseware/bridge.js new file mode 100644 index 000000000000..f2eb9e25e21c --- /dev/null +++ b/lms/static/js/courseware/bridge.js @@ -0,0 +1,101 @@ +/** + * JS bridge for communication between the native mobile apps and the xblock. + * + * This script is used to send data about student's answer to the native mobile apps (IOS and Android) + * and to receive data about student's answer from the native mobile apps to fill the form + * with the student's answer, disable xblock inputs and mark the problem as completed. + * + * Separate functions for each platform allow you to flexibly add platform-specific logic + * as needed without changing the naming on the mobile side. + */ + +/** + * Sends a JSON-formatted message to the iOS bridge if available. + * @param {string} message - The JSON message to send. + */ +function sendMessageToIOS(message) { + try { + if (window?.webkit?.messageHandlers?.IOSBridge) { + window.webkit.messageHandlers.IOSBridge.postMessage(message); + console.log("Message sent to iOS:", message); + } + } catch (error) { + console.error("Failed to send message to iOS:", error); + } +} + +/** + * Sends a JSON-formatted message to the Android bridge if available. + * @param {string} message - The JSON message to send. + */ +function sendMessageToAndroid(message) { + try { + if (window?.AndroidBridge) { + window.AndroidBridge.postMessage(message); + console.log("Message sent to Android:", message); + } + } catch (error) { + console.error("Failed to send message to Android:", error); + } +} + +/** + * Receives a message from the mobile apps and fills the form with the student's answer, + * disables xblock inputs and marks the problem as completed with appropriate message. + * + * @param {string} message The stringified JSON object about the student's answer from the native mobile app. + */ +function markProblemCompleted(message) { + let data; + try { + data = JSON.parse(message).data + } catch (error) { + console.error("Failed to parse message:", error) + return + } + const problemContainer = $(".xblock-student_view"); + + const submitButton = problemContainer.find(".submit-attempt-container .submit"); + const notificationContainer = problemContainer.find(".notification-gentle-alert"); + + submitButton.attr({disabled: "disabled"}); + notificationContainer.find(".notification-message").text("Answer submitted"); + notificationContainer.find(".icon").remove(); + notificationContainer.show(); + + data.split("&").forEach(function (item) { + const [inputId, answer] = item.split('=', 2); + problemContainer.find( + `input[id$="${answer}"], input[id$="${inputId}"]` + ).each(function () { + this.disabled = true; + if (this.type === "checkbox" || this.type === "radio") { + this.checked = true; + } else { + this.value = answer; + } + }) + }) +} + +/** + * Overrides the default $.ajax function to intercept the requests to the "handler/xmodule_handler/problem_check" + * endpoint and send the data to the native mobile apps. + * + * @param {Object} options The data object for the ajax request + */ +const originalAjax = $.ajax; +$.ajax = function (options) { + if (options.url && options.url.endsWith("handler/xmodule_handler/problem_check")) { + if (options.data) { + // Replace spaces with URLEncoded value to ensure correct parsing on the backend + let optionsCopy = {...options}; + optionsCopy.data = optionsCopy.data.replace(/\+/g, '%20'); + + sendMessageToIOS(JSON.stringify(optionsCopy)) + sendMessageToAndroid(JSON.stringify(optionsCopy)) + } + } + return originalAjax.call(this, options); +} + diff --git a/lms/static/js/spec/courseware/bridge_spec.js b/lms/static/js/spec/courseware/bridge_spec.js new file mode 100644 index 000000000000..13fd8dbeb6f1 --- /dev/null +++ b/lms/static/js/spec/courseware/bridge_spec.js @@ -0,0 +1,97 @@ +describe('JS bridge for communication between native mobile apps and the xblock', function() { + beforeEach(function() { + // Mock objects for IOS and Android bridges + window.webkit = { + messageHandlers: { + IOSBridge: { + postMessage: jasmine.createSpy('postMessage') + } + } + }; + window.AndroidBridge = { + postMessage: jasmine.createSpy('postMessage') + }; + }); + + describe('sendMessageToIOS', function() { + it('should call postMessage on IOSBridge with the correct message', function() { + const message = JSON.stringify({answer: 'test'}); + sendMessageToIOS(message); + expect(window.webkit.messageHandlers.IOSBridge.postMessage).toHaveBeenCalledWith(message); + }); + }); + + describe('sendMessageToAndroid', function() { + it('should call postMessage on AndroidBridge with the correct message', function() { + const message = JSON.stringify({answer: 'test'}); + sendMessageToAndroid(message); + expect(window.AndroidBridge.postMessage).toHaveBeenCalledWith(message); + }); + }); + + describe('markProblemCompleted', function() { + it('should correctly parse the message and update the DOM elements', function() { + const message = JSON.stringify({ + data: 'input1=answer1&input2=answer2' + }); + const problemContainer = $('
Test HTML Content
' + ) + self.problem_block = BlockFactory.create( # lint-amnesty, pylint: disable=attribute-defined-outside-init + parent=self.vertical_block, + category='problem', + display_name='Problem xblock for Offline', + data=problem_xml + ) diff --git a/openedx/features/offline_mode/tests/test_assets_management.py b/openedx/features/offline_mode/tests/test_assets_management.py new file mode 100644 index 000000000000..c40629860ad1 --- /dev/null +++ b/openedx/features/offline_mode/tests/test_assets_management.py @@ -0,0 +1,390 @@ +""" +Tests for the testing utility functions for managing assets and files for Offline Mode. +""" + +import os + +from datetime import datetime +from unittest import TestCase +from unittest.mock import MagicMock, Mock, call, patch + +from botocore.exceptions import ClientError +from django.conf import settings +from path import Path +from pytz import UTC + +from openedx.features.offline_mode.assets_management import ( + block_storage_path, + clean_outdated_xblock_files, + create_subdirectories_for_asset, + get_offline_block_content_path, + get_static_file_path, + is_modified, + save_asset_file, + save_mathjax_to_xblock_assets, +) +from openedx.features.offline_mode.constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH +from xmodule.modulestore.exceptions import ItemNotFoundError + + +class AssetsManagementTestCase(TestCase): + """ + Test case for the testing utility functions for managing assets and files. + """ + + def test_get_static_file_path(self) -> None: + relative_path_mock = 'relative_path_mock' + expected_result = Path(f'{settings.STATIC_ROOT}/{relative_path_mock}') + + result = get_static_file_path(relative_path_mock) + + self.assertEqual(result, expected_result) + + @patch('openedx.features.offline_mode.assets_management.open') + @patch('openedx.features.offline_mode.assets_management.create_subdirectories_for_asset') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + @patch('openedx.features.offline_mode.assets_management.AssetManager.find') + @patch('openedx.features.offline_mode.assets_management.StaticContent.get_asset_key_from_path') + def test_save_asset_file_if_filename_contains_slash( + self, + get_asset_key_from_path_mock: MagicMock, + asset_manager_find_mock: MagicMock, + os_path_join_mock: MagicMock, + create_subdirectories_for_asset_mock: MagicMock, + context_manager_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + xblock_mock = Mock() + path_mock = 'path_mock' + filename_mock = 'assets/filename_mock' + + save_asset_file(temp_dir_mock, xblock_mock, path_mock, filename_mock) + + get_asset_key_from_path_mock.assert_called_once_with( + xblock_mock.location.course_key, filename_mock.split('/')[-1] + ) + asset_manager_find_mock.assert_called_once_with(get_asset_key_from_path_mock.return_value) + os_path_join_mock.assert_called_once_with(temp_dir_mock, filename_mock) + create_subdirectories_for_asset_mock.assert_called_once_with(os_path_join_mock.return_value) + context_manager_mock.assert_called_once_with(os_path_join_mock.return_value, 'wb') + context_manager_mock.return_value.__enter__.return_value.write.assert_called_once_with( + asset_manager_find_mock.return_value.data + ) + + @patch('openedx.features.offline_mode.assets_management.open') + @patch('openedx.features.offline_mode.assets_management.create_subdirectories_for_asset') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + @patch('openedx.features.offline_mode.assets_management.read_static_file') + @patch('openedx.features.offline_mode.assets_management.get_static_file_path') + def test_save_asset_file_no_slash_in_filename( + self, + get_static_file_path_mock: MagicMock, + read_static_file_mock: MagicMock, + os_path_join_mock: MagicMock, + create_subdirectories_for_asset_mock: MagicMock, + context_manager_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + xblock_mock = Mock() + path_mock = 'path_mock' + filename_mock = 'filename_mock' + + save_asset_file(temp_dir_mock, xblock_mock, path_mock, filename_mock) + + get_static_file_path_mock.assert_called_once_with(filename_mock) + read_static_file_mock.assert_called_once_with(get_static_file_path_mock.return_value) + os_path_join_mock.assert_called_once_with( + temp_dir_mock, 'assets', filename_mock, + ) + create_subdirectories_for_asset_mock.assert_called_once_with(os_path_join_mock.return_value) + context_manager_mock.assert_called_once_with(os_path_join_mock.return_value, 'wb') + context_manager_mock.return_value.__enter__.return_value.write.assert_called_once_with( + read_static_file_mock.return_value + ) + + @patch('openedx.features.offline_mode.assets_management.log.warning') + @patch( + 'openedx.features.offline_mode.assets_management.get_static_file_path', side_effect=ItemNotFoundError + ) + def test_save_asset_file_can_not_find( + self, + get_static_file_path_mock: MagicMock, + log_warning_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + xblock_mock = Mock() + path_mock = 'path_mock' + filename_mock = 'filename_mock' + + save_asset_file(temp_dir_mock, xblock_mock, path_mock, filename_mock) + + get_static_file_path_mock.assert_called_once_with(filename_mock) + log_warning_mock.assert_called_once_with( + f'Asset not found: {filename_mock}, during offline content generation.' + ) + + @patch('openedx.features.offline_mode.assets_management.os') + def test_create_subdirectories_for_asset_subdirectories_does_not_exist(self, os_mock: MagicMock) -> None: + file_path_mock = 'file/path/mock/' + os_mock.path.exists.return_value = False + + expected_os_path_join_call_args_list = [ + call('/', 'file'), + call(os_mock.path.join.return_value, 'path'), + call(os_mock.path.join.return_value, 'mock'), + ] + expected_os_mock_mkdir_call_args_list = [ + call(os_mock.path.join.return_value), + call(os_mock.path.join.return_value), + call(os_mock.path.join.return_value), + ] + + create_subdirectories_for_asset(file_path_mock) + + self.assertListEqual(os_mock.path.join.call_args_list, expected_os_path_join_call_args_list) + self.assertListEqual(os_mock.mkdir.call_args_list, expected_os_mock_mkdir_call_args_list) + + @patch('openedx.features.offline_mode.assets_management.os') + def test_create_subdirectories_for_asset_subdirectories_exist(self, os_mock: MagicMock) -> None: + file_path_mock = 'file/path/mock/' + + expected_os_path_join_call_args_list = [ + call('/', 'file'), + call(os_mock.path.join.return_value, 'path'), + call(os_mock.path.join.return_value, 'mock'), + ] + + create_subdirectories_for_asset(file_path_mock) + + self.assertListEqual(os_mock.path.join.call_args_list, expected_os_path_join_call_args_list) + os_mock.mkdir.assert_not_called() + + @patch('openedx.features.offline_mode.assets_management.log') + @patch('openedx.features.offline_mode.assets_management.default_storage') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_clean_outdated_xblock_files_successful( + self, + block_storage_path_mock: MagicMock, + default_storage_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + default_storage_mock.exists.return_value = True + expected_offline_zip_path = os.path.join( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip' + ) + + clean_outdated_xblock_files(xblock_mock) + + block_storage_path_mock.assert_called_once_with(xblock_mock) + default_storage_mock.exists.assert_called_once_with(expected_offline_zip_path) + default_storage_mock.delete.assert_called_once_with(expected_offline_zip_path) + logger_mock.info.assert_called_once_with(f'Successfully deleted the file: {expected_offline_zip_path}') + + @patch('openedx.features.offline_mode.assets_management.log') + @patch('openedx.features.offline_mode.assets_management.default_storage') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_clean_outdated_xblock_files_does_not_exist( + self, + block_storage_path_mock: MagicMock, + default_storage_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + default_storage_mock.exists.return_value = False + expected_offline_zip_path = os.path.join( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip' + ) + + clean_outdated_xblock_files(xblock_mock) + + block_storage_path_mock.assert_called_once_with(xblock_mock) + default_storage_mock.exists.assert_called_once_with(expected_offline_zip_path) + default_storage_mock.delete.assert_not_called() + logger_mock.info.assert_not_called() + + @patch('openedx.features.offline_mode.assets_management.log.error') + @patch('openedx.features.offline_mode.assets_management.default_storage.exists') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_remove_old_files_client_error( + self, + block_storage_path_mock: MagicMock, + default_storage_exists_mock: MagicMock, + log_error_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + default_storage_exists_mock.side_effect = ClientError( + operation_name='InvalidKeyPair.Duplicate', error_response={ + 'Error': {'Code': 'Duplicate', 'Message': 'Invalid File Path'} + } + ) + expected_error_message = ( + 'An error occurred (Duplicate) when calling the InvalidKeyPair.Duplicate operation: Invalid File Path' + ) + + clean_outdated_xblock_files(xblock_mock) + block_storage_path_mock.assert_called_once_with(xblock_mock) + log_error_mock.assert_called_once_with( + f'Error occurred while deleting the files or directory: {expected_error_message}' + ) + + @patch('openedx.features.offline_mode.assets_management.default_storage.exists') + @patch('openedx.features.offline_mode.assets_management.os.path.join', return_value='offline_zip_path_mock') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_get_offline_block_content_path_offline_content_exists( + self, + block_storage_path_mock: MagicMock, + os_path_join_mock: MagicMock, + default_storage_exists_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + + result = get_offline_block_content_path(xblock_mock) + + block_storage_path_mock.assert_called_once_with(usage_key=xblock_mock.location) + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip' + ) + default_storage_exists_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, 'offline_zip_path_mock') + + @patch('openedx.features.offline_mode.assets_management.default_storage.exists', return_value=False) + @patch('openedx.features.offline_mode.assets_management.os.path.join', return_value='offline_zip_path_mock') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_get_offline_block_content_path_does_not_exist( + self, + block_storage_path_mock: MagicMock, + os_path_join_mock: MagicMock, + default_storage_exists_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + + result = get_offline_block_content_path(xblock_mock) + + block_storage_path_mock.assert_called_once_with(usage_key=xblock_mock.location) + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip' + ) + default_storage_exists_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, None) + + def test_block_storage_path_exists(self) -> None: + xblock_mock = Mock(location=Mock(course_key='course_key_mock')) + + result = block_storage_path(xblock_mock) + + self.assertEqual(result, 'offline_content/course_key_mock/') + + def test_block_storage_path_does_not_exists(self) -> None: + result = block_storage_path() + + self.assertEqual(result, '') + + @patch( + 'openedx.features.offline_mode.assets_management.default_storage.get_modified_time', + return_value=datetime(2024, 6, 12, tzinfo=UTC) + ) + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + def test_is_modified_true( + self, + os_path_join_mock: MagicMock, + block_storage_path_mock: MagicMock, + get_created_time_mock: MagicMock, + ) -> None: + xblock_mock = Mock(published_on=datetime(2024, 6, 13, tzinfo=UTC)) + + result = is_modified(xblock_mock) + + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip') + get_created_time_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, True) + + @patch( + 'openedx.features.offline_mode.assets_management.default_storage.get_modified_time', + return_value=datetime(2024, 6, 12, tzinfo=UTC) + ) + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + def test_is_modified_false( + self, + os_path_join_mock: MagicMock, + block_storage_path_mock: MagicMock, + get_created_time_mock: MagicMock, + ) -> None: + xblock_mock = Mock(published_on=datetime(2024, 6, 1, tzinfo=UTC)) + + result = is_modified(xblock_mock) + + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip') + get_created_time_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, False) + + @patch( + 'openedx.features.offline_mode.assets_management.default_storage.get_modified_time', + side_effect=OSError + ) + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + def test_is_modified_os_error( + self, + os_path_join_mock: MagicMock, + block_storage_path_mock: MagicMock, + get_created_time_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + + result = is_modified(xblock_mock) + + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip') + get_created_time_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, True) + + @patch('openedx.features.offline_mode.assets_management.log.info') + @patch('openedx.features.offline_mode.assets_management.open') + @patch('openedx.features.offline_mode.assets_management.requests.get') + @patch('openedx.features.offline_mode.assets_management.os') + def test_save_mathjax_to_xblock_assets_successfully( + self, + os_mock: MagicMock, + requests_get_mock: MagicMock, + context_manager_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + os_mock.path.exists.return_value = False + + save_mathjax_to_xblock_assets(temp_dir_mock) + + os_mock.path.join.assert_called_once_with(temp_dir_mock, MATHJAX_STATIC_PATH) + os_mock.path.exists.assert_called_once_with(os_mock.path.join.return_value) + requests_get_mock.assert_called_once_with(MATHJAX_CDN_URL) + context_manager_mock.assert_called_once_with(os_mock.path.join.return_value, 'wb') + context_manager_mock.return_value.__enter__.return_value.write.assert_called_once_with( + requests_get_mock.return_value.content + ) + logger_mock.assert_called_once_with(f'Successfully saved MathJax to {os_mock.path.join.return_value}') + + @patch('openedx.features.offline_mode.assets_management.log.info') + @patch('openedx.features.offline_mode.assets_management.open') + @patch('openedx.features.offline_mode.assets_management.requests.get') + @patch('openedx.features.offline_mode.assets_management.os') + def test_save_mathjax_to_xblock_assets_already_exists( + self, + os_mock: MagicMock, + requests_get_mock: MagicMock, + context_manager_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + + save_mathjax_to_xblock_assets(temp_dir_mock) + + os_mock.path.join.assert_called_once_with(temp_dir_mock, MATHJAX_STATIC_PATH) + os_mock.path.exists.assert_called_once_with(os_mock.path.join.return_value) + requests_get_mock.assert_not_called() + context_manager_mock.assert_not_called() + logger_mock.assert_not_called() diff --git a/openedx/features/offline_mode/tests/test_html_manipulator.py b/openedx/features/offline_mode/tests/test_html_manipulator.py new file mode 100644 index 000000000000..cf280900177e --- /dev/null +++ b/openedx/features/offline_mode/tests/test_html_manipulator.py @@ -0,0 +1,163 @@ +""" +Tests for the testing methods for prepare HTML content for offline using. +""" + +from bs4 import BeautifulSoup +from unittest import TestCase +from unittest.mock import MagicMock, Mock, call, patch + +from openedx.features.offline_mode.constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH +from openedx.features.offline_mode.html_manipulator import HtmlManipulator + + +class HtmlManipulatorTestCase(TestCase): + """ + Test case for the testing `HtmlManipulator` methods. + """ + + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_iframe') + @patch('openedx.features.offline_mode.html_manipulator.BeautifulSoup', return_value='soup_mock') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._copy_platform_fonts') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_external_links') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_mathjax_link') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_static_links') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_asset_links') + def test_process_html( + self, + replace_asset_links_mock: MagicMock, + replace_static_links_mock: MagicMock, + replace_mathjax_link_mock: MagicMock, + replace_external_links: MagicMock, + copy_platform_fonts: MagicMock, + beautiful_soup_mock: MagicMock, + replace_iframe_mock: MagicMock, + ) -> None: + html_data_mock = 'html_data_mock' + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + expected_result = 'soup_mock' + + result = html_manipulator.process_html() + + replace_asset_links_mock.assert_called_once_with() + replace_static_links_mock.assert_called_once_with() + replace_mathjax_link_mock.assert_called_once_with() + replace_external_links.assert_called_once_with() + copy_platform_fonts.assert_called_once_with() + beautiful_soup_mock.assert_called_once_with(html_manipulator.html_data, 'html.parser') + replace_iframe_mock.assert_called_once_with(beautiful_soup_mock.return_value) + self.assertEqual(result, expected_result) + + @patch('openedx.features.offline_mode.html_manipulator.save_mathjax_to_xblock_assets') + def test_replace_mathjax_link(self, save_mathjax_to_xblock_assets: MagicMock) -> None: + html_data_mock = f'' + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + + expected_html_data_after_replacing = f'' + + self.assertEqual(html_manipulator.html_data, html_data_mock) + + html_manipulator._replace_mathjax_link() # lint-amnesty, pylint: disable=protected-access + + save_mathjax_to_xblock_assets.assert_called_once_with(html_manipulator.temp_dir) + self.assertEqual(html_manipulator.html_data, expected_html_data_after_replacing) + + @patch('openedx.features.offline_mode.html_manipulator.save_asset_file') + def test_replace_static_links(self, save_asset_file_mock: MagicMock) -> None: + html_data_mock = '
Test HTML Content
', result) + + def test_render_xblock_from_lms_problem_block(self): + xblock_renderer = XBlockRenderer(str(self.problem_block.location), user=self.user) + + result = xblock_renderer.render_xblock_from_lms() + + self.assertIsNotNone(result) + self.assertEqual(type(result), str) + self.assertIn('Problem xblock for Offline', result) diff --git a/openedx/features/offline_mode/tests/test_storage_management.py b/openedx/features/offline_mode/tests/test_storage_management.py new file mode 100644 index 000000000000..e8e9d3c093bb --- /dev/null +++ b/openedx/features/offline_mode/tests/test_storage_management.py @@ -0,0 +1,270 @@ +""" +Tests for the testing Offline Mode storage management. +""" + +import os +import shutil +from unittest import TestCase +from unittest.mock import MagicMock, Mock, call, patch + +from django.http.response import Http404 + +from openedx.features.offline_mode.constants import MATHJAX_STATIC_PATH +from openedx.features.offline_mode.storage_management import OfflineContentGenerator +from openedx.features.offline_mode.tests.base import CourseForOfflineTestCase + + +class OfflineContentGeneratorTestCase(TestCase): + """ + Test case for the testing Offline Mode utils. + """ + @patch('openedx.features.offline_mode.storage_management.XBlockRenderer') + def test_render_block_html_data_successful(self, xblock_renderer_mock: MagicMock) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + + result = OfflineContentGenerator(xblock_mock, html_data_mock).render_block_html_data() + + xblock_renderer_mock.assert_called_once_with(str(xblock_mock.location)) + xblock_renderer_mock.return_value.render_xblock_from_lms.assert_called_once_with() + self.assertEqual(result, xblock_renderer_mock.return_value.render_xblock_from_lms.return_value) + + @patch('openedx.features.offline_mode.storage_management.XBlockRenderer') + def test_render_block_html_data_successful_no_html_data(self, xblock_renderer_mock: MagicMock) -> None: + xblock_mock = Mock() + expected_xblock_renderer_args_list = [call(str(xblock_mock.location)), call(str(xblock_mock.location))] + + result = OfflineContentGenerator(xblock_mock).render_block_html_data() + + self.assertListEqual(xblock_renderer_mock.call_args_list, expected_xblock_renderer_args_list) + self.assertListEqual( + xblock_renderer_mock.return_value.render_xblock_from_lms.call_args_list, [call(), call()] + ) + self.assertEqual(result, xblock_renderer_mock.return_value.render_xblock_from_lms.return_value) + + @patch('openedx.features.offline_mode.storage_management.log.error') + @patch('openedx.features.offline_mode.storage_management.XBlockRenderer', side_effect=Http404) + def test_render_block_html_data_http404( + self, + xblock_renderer_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + + with self.assertRaises(Http404): + OfflineContentGenerator(xblock_mock, html_data_mock).render_block_html_data() + + xblock_renderer_mock.assert_called_once_with(str(xblock_mock.location)) + logger_mock.assert_called_once_with( + f'Block {str(xblock_mock.location)} cannot be fetched from course' + f' {xblock_mock.location.course_key} during offline content generation.' + ) + + @patch('openedx.features.offline_mode.storage_management.shutil.rmtree') + @patch('openedx.features.offline_mode.storage_management.OfflineContentGenerator.create_zip_file') + @patch('openedx.features.offline_mode.storage_management.OfflineContentGenerator.save_xblock_html') + @patch('openedx.features.offline_mode.storage_management.mkdtemp') + @patch('openedx.features.offline_mode.storage_management.clean_outdated_xblock_files') + @patch('openedx.features.offline_mode.storage_management.block_storage_path') + def test_generate_offline_content_for_modified_xblock( + self, + block_storage_path_mock: MagicMock, + clean_outdated_xblock_files_mock: MagicMock, + mkdtemp_mock: MagicMock, + save_xblock_html_mock: MagicMock, + create_zip_file_mock: MagicMock, + shutil_rmtree_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + + OfflineContentGenerator(xblock_mock, html_data_mock).generate_offline_content() + + block_storage_path_mock.assert_called_once_with(xblock_mock) + clean_outdated_xblock_files_mock.assert_called_once_with(xblock_mock) + mkdtemp_mock.assert_called_once_with() + save_xblock_html_mock.assert_called_once_with(mkdtemp_mock.return_value) + create_zip_file_mock.assert_called_once_with( + mkdtemp_mock.return_value, + block_storage_path_mock.return_value, + f'{xblock_mock.location.block_id}.zip' + ) + shutil_rmtree_mock.assert_called_once_with(mkdtemp_mock.return_value, ignore_errors=True) + + @patch('openedx.features.offline_mode.storage_management.os.path.join') + @patch('openedx.features.offline_mode.storage_management.open') + @patch('openedx.features.offline_mode.storage_management.HtmlManipulator') + def test_save_xblock_html( + self, + html_manipulator_mock: MagicMock, + context_manager_mock: MagicMock, + os_path_join_mock: MagicMock, + ) -> None: + tmp_dir_mock = Mock() + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + + OfflineContentGenerator(xblock_mock, html_data_mock).save_xblock_html(tmp_dir_mock) + + html_manipulator_mock.assert_called_once_with(xblock_mock, html_data_mock, tmp_dir_mock) + html_manipulator_mock.return_value.process_html.assert_called_once_with() + context_manager_mock.assert_called_once_with(os_path_join_mock.return_value, 'w') + os_path_join_mock.assert_called_once_with(tmp_dir_mock, 'index.html') + context_manager_mock.return_value.__enter__.return_value.write.assert_called_once_with( + html_manipulator_mock.return_value.process_html.return_value + ) + + @patch('openedx.features.offline_mode.storage_management.log.info') + @patch('openedx.features.offline_mode.storage_management.ContentFile') + @patch('openedx.features.offline_mode.storage_management.open') + @patch('openedx.features.offline_mode.storage_management.get_storage') + @patch('openedx.features.offline_mode.storage_management.OfflineContentGenerator.add_files_to_zip_recursively') + @patch('openedx.features.offline_mode.storage_management.ZipFile') + def test_create_zip_file( + self, + zip_file_context_manager: MagicMock, + add_files_to_zip_recursively_mock: MagicMock, + storage_mock: MagicMock, + open_context_manager_mock: MagicMock, + content_file_mock: MagicMock, + log_info_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + temp_dir_mock = 'temp_dir_mock' + base_path_mock = 'base_path_mock' + file_name_mock = 'file_name_mock' + + OfflineContentGenerator(xblock_mock, html_data_mock).create_zip_file( + temp_dir_mock, base_path_mock, file_name_mock + ) + + zip_file_context_manager.assert_called_once_with(os.path.join(temp_dir_mock, file_name_mock), 'w') + zip_file_context_manager.return_value.__enter__.return_value.write.assert_called_once_with( + os.path.join(temp_dir_mock, 'index.html'), 'index.html' + ) + add_files_to_zip_recursively_mock.assert_called_once_with( + zip_file_context_manager.return_value.__enter__.return_value, + current_base_path=os.path.join(temp_dir_mock, 'assets'), + current_path_in_zip='assets', + ) + open_context_manager_mock.assert_called_once_with(os.path.join(temp_dir_mock, file_name_mock), 'rb') + content_file_mock.assert_called_once_with( + open_context_manager_mock.return_value.__enter__.return_value.read.return_value + ) + storage_mock.return_value.save.assert_called_once_with( + os.path.join(base_path_mock + file_name_mock), content_file_mock.return_value + ) + log_info_mock.assert_called_once_with( + f'Offline content for {file_name_mock} has been generated.' + ) + + @patch('openedx.features.offline_mode.storage_management.os') + def test_add_files_to_zip_recursively_successfully_for_file( + self, + os_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + zip_file_mock = Mock() + current_base_path_mock = 'current_base_path_mock' + current_path_in_zip_mock = 'current_path_in_zip_mock' + resource_path_mock = 'resource_path_mock' + os_mock.listdir.return_value = [resource_path_mock] + + expected_os_mock_path_join_calls = [ + call(current_base_path_mock, resource_path_mock), + call(current_path_in_zip_mock, resource_path_mock) + ] + + OfflineContentGenerator(xblock_mock, html_data_mock).add_files_to_zip_recursively( + zip_file_mock, current_base_path_mock, current_path_in_zip_mock + ) + + os_mock.listdir.assert_called_once_with(current_base_path_mock) + self.assertListEqual(os_mock.path.join.call_args_list, expected_os_mock_path_join_calls) + zip_file_mock.write.assert_called_once_with(os_mock.path.join.return_value, os_mock.path.join.return_value) + + @patch('openedx.features.offline_mode.storage_management.OfflineContentGenerator.add_files_to_zip_recursively') + @patch('openedx.features.offline_mode.storage_management.os.listdir') + def test_add_files_to_zip_recursively_successfully_recursively_path( + self, + os_listdir_mock: MagicMock, + add_files_to_zip_recursively_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + zip_file_mock = Mock() + current_base_path_mock = 'current_base_path_mock' + current_path_in_zip_mock = 'current_path_in_zip_mock' + resource_path_mock = 'resource_path_mock' + os_listdir_mock.listdir.return_value = [resource_path_mock] + + OfflineContentGenerator(xblock_mock, html_data_mock).add_files_to_zip_recursively( + zip_file_mock, current_base_path_mock, current_path_in_zip_mock + ) + + add_files_to_zip_recursively_mock.assert_called_once_with( + zip_file_mock, current_base_path_mock, current_path_in_zip_mock + ) + + @patch('openedx.features.offline_mode.storage_management.log.error') + @patch('openedx.features.offline_mode.storage_management.os.listdir', side_effect=OSError) + def test_add_files_to_zip_recursively_with_os_error( + self, + os_mock: MagicMock, + log_error_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + zip_file_mock = Mock() + current_base_path_mock = 'current_base_path_mock' + current_path_in_zip_mock = 'current_path_in_zip_mock' + + OfflineContentGenerator(xblock_mock, html_data_mock).add_files_to_zip_recursively( + zip_file_mock, current_base_path_mock, current_path_in_zip_mock + ) + + os_mock.assert_called_once_with(current_base_path_mock) + log_error_mock.assert_called_once_with(f'Error while reading the directory: {current_base_path_mock}') + + +class OfflineContentGeneratorFunctionalTestCase(CourseForOfflineTestCase): + """ + Tests creating Offline Content in storage. + """ + + def setUp(self): + super().setUp() + self.html_data = '
Test HTML Content
' # lint-amnesty, pylint: disable=attribute-defined-outside-init + + @patch('openedx.features.offline_mode.html_manipulator.save_mathjax_to_xblock_assets') + def test_generate_offline_content(self, save_mathjax_to_xblock_assets_mock): + OfflineContentGenerator(self.html_block, self.html_data).generate_offline_content() + + expected_offline_content_path = ( + 'test_root/uploads/offline_content/course-v1:RaccoonGang+1+2024/HTML_xblock_for_Offline.zip' + ) + + save_mathjax_to_xblock_assets_mock.assert_called_once() + self.assertTrue(os.path.exists(expected_offline_content_path)) + shutil.rmtree('test_root/uploads/offline_content/course-v1:RaccoonGang+1+2024', ignore_errors=True) + + def test_save_xblock_html_to_temp_dir(self): + shutil.rmtree('test_root/assets', ignore_errors=True) + temp_dir = 'test_root/' + os.makedirs('test_root/assets/js/') + OfflineContentGenerator(self.html_block, self.html_data).save_xblock_html(temp_dir) + + expected_index_html_path = 'test_root/index.html' + expected_mathjax_static_path = os.path.join(temp_dir, MATHJAX_STATIC_PATH) + + self.assertTrue(os.path.exists(expected_index_html_path)) + self.assertTrue(os.path.exists(expected_mathjax_static_path)) + with open(expected_index_html_path, 'r') as content: + html_data = content.read() + self.assertIn(self.html_data, html_data) + + shutil.rmtree('test_root/assets', ignore_errors=True) + os.remove(expected_index_html_path) diff --git a/openedx/features/offline_mode/tests/test_tasks.py b/openedx/features/offline_mode/tests/test_tasks.py new file mode 100644 index 000000000000..dc7ac8b5ccc8 --- /dev/null +++ b/openedx/features/offline_mode/tests/test_tasks.py @@ -0,0 +1,131 @@ +""" +Tests for the testing Offline Mode tacks. +""" + +import pytest +from unittest.mock import MagicMock, Mock, call, patch + +from ddt import data, ddt, unpack +from django.conf import settings +from django.http.response import Http404 + +from common.djangoapps.student.tests.factories import UserFactory +from opaque_keys.edx.keys import CourseKey +from openedx.features.offline_mode.constants import OFFLINE_SUPPORTED_XBLOCKS +from openedx.features.offline_mode.tasks import ( + generate_offline_content_for_block, + generate_offline_content_for_course, +) + +from .base import CourseForOfflineTestCase + + +@pytest.mark.django_db +@ddt +class GenerateOfflineContentTasksTestCase(CourseForOfflineTestCase): + """ + Test case for the testing generating offline content tacks. + """ + + def setUp(self) -> None: + self.user = UserFactory(username=settings.OFFLINE_SERVICE_WORKER_USERNAME) + super().setUp() + + @patch('openedx.features.offline_mode.tasks.OfflineContentGenerator') + @patch('openedx.features.offline_mode.tasks.is_modified', return_value=True) + def test_generate_offline_content_for_block_success( + self, + is_modified_mock: MagicMock, + offline_content_generator_mock: MagicMock, + ) -> None: + generate_offline_content_for_block(str(self.html_block.location)) + + is_modified_mock.assert_called() + offline_content_generator_mock.assert_called_once() + offline_content_generator_mock.return_value.generate_offline_content.assert_called_once() + + @patch('openedx.features.offline_mode.tasks.OfflineContentGenerator') + @patch('openedx.features.offline_mode.tasks.modulestore', side_effect=Http404) + def test_generate_offline_content_for_block_with_exception_in_offline_content_generation( + self, + modulestore_mock: MagicMock, + offline_content_generator_mock: MagicMock, + ) -> None: + with pytest.raises(Http404): + generate_offline_content_for_block(str(self.html_block.location)) + + modulestore_mock.assert_called_once() + offline_content_generator_mock.assert_not_called() + offline_content_generator_mock.return_value.generate_offline_content.assert_not_called() + + @patch('openedx.features.offline_mode.tasks.generate_offline_content_for_block') + @patch('openedx.features.offline_mode.tasks.is_modified') + def test_generate_offline_content_for_course_supported_block_types( + self, + is_modified_mock: MagicMock, + generate_offline_content_for_block_mock: MagicMock, + ) -> None: + is_modified_mock.return_value = True + + generate_offline_content_for_course(str(self.course.id)) + + generate_offline_content_for_block_mock.assert_has_calls( + [ + call.apply_async([str(self.html_block.location)]), + call.apply_async([str(self.problem_block.location)]), + ], + ) + + @patch('openedx.features.offline_mode.tasks.generate_offline_content_for_block') + @patch('openedx.features.offline_mode.tasks.is_modified') + @patch('openedx.features.offline_mode.tasks.modulestore') + @data( + (False, False), + (True, False), + (False, True), + ) + @unpack + def test_generate_offline_content_for_course_supported_block_types_for_closed_or_not_modified_xblock( + self, + is_modified_value_mock: bool, + is_closed_value_mock: bool, + modulestore_mock: MagicMock, + is_modified_mock: MagicMock, + generate_offline_content_for_block_mock: MagicMock, + ) -> None: + xblock_location_mock = 'xblock_location_mock' + modulestore_mock.return_value.get_items.return_value = [ + Mock(location=xblock_location_mock, closed=Mock(return_value=is_closed_value_mock)) + ] + is_modified_mock.return_value = is_modified_value_mock + + expected_call_args_for_modulestore_get_items = [ + call(CourseKey.from_string(str(self.course.id)), qualifiers={'category': offline_supported_block_type}) + for offline_supported_block_type in OFFLINE_SUPPORTED_XBLOCKS + ] + + generate_offline_content_for_course(str(self.course.id)) + + self.assertEqual(modulestore_mock.call_count, len(OFFLINE_SUPPORTED_XBLOCKS)) + self.assertListEqual( + modulestore_mock.return_value.get_items.call_args_list, expected_call_args_for_modulestore_get_items + ) + generate_offline_content_for_block_mock.assert_not_called() + + @patch('openedx.features.offline_mode.tasks.generate_offline_content_for_block') + @patch('openedx.features.offline_mode.tasks.is_modified') + @patch('openedx.features.offline_mode.tasks.modulestore') + def test_generate_offline_content_for_course_unsupported_block_type( + self, + modulestore_mock: MagicMock, + is_modified_mock: MagicMock, + generate_offline_content_for_block_mock: MagicMock, + ) -> None: + is_modified_mock.return_value = True + xblock_mock = Mock(closed=Mock(return_value=False)) + modulestore_mock.get_item.return_value = xblock_mock + xblock_mock.block_type = 'unsupported_block_type' + + generate_offline_content_for_course(str(self.course.id)) + + generate_offline_content_for_block_mock.assert_not_called() 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/utils.py b/openedx/features/offline_mode/utils.py new file mode 100644 index 000000000000..f6f153fcbb4a --- /dev/null +++ b/openedx/features/offline_mode/utils.py @@ -0,0 +1,54 @@ +""" +Utility functions for offline mode. +""" +import logging +import os + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.files.storage import default_storage + +from xmodule.modulestore.django import modulestore + +from .constants import OFFLINE_SUPPORTED_XBLOCKS + +User = get_user_model() +log = logging.getLogger(__name__) + + +def get_offline_service_user(): + """ + Get the service user to render XBlock. + """ + try: + return User.objects.get(username=settings.OFFLINE_SERVICE_WORKER_USERNAME) + except User.DoesNotExist as e: + log.error( + f"Service user with username {settings.OFFLINE_SERVICE_WORKER_USERNAME} to render XBlock does not exist." + ) + raise e + + +def clear_deleted_content(course_key): + """ + Delete the offline content archive for the blocks that are deleted from the course. + """ + base_offline_course_path = settings.OFFLINE_CONTENT_PATH_TEMPLATE.format(course_id=str(course_key)) + if default_storage.exists(base_offline_course_path): + _, file_names = default_storage.listdir(base_offline_course_path) + else: + return + + all_course_offline_archive_names = { + f"{xblock.location.block_id}.zip" + for block_type in OFFLINE_SUPPORTED_XBLOCKS + for xblock in modulestore().get_items(course_key, qualifiers={"category": block_type}) + } + + files_to_delete = set(file_names) - all_course_offline_archive_names + + for file_name in files_to_delete: + file_path = os.path.join(base_offline_course_path, file_name) + if default_storage.exists(file_path): + default_storage.delete(file_path) + log.info(f"Successfully deleted the file: {file_path}") diff --git a/uwsgi.ini b/uwsgi.ini new file mode 100755 index 000000000000..e69de29bb2d1 diff --git a/xmodule/modulestore/django.py b/xmodule/modulestore/django.py index c15f03a4e33d..111b9aa638cc 100644 --- a/xmodule/modulestore/django.py +++ b/xmodule/modulestore/django.py @@ -187,13 +187,14 @@ def do_my_expensive_update(course_key): pre_publish = SwitchedSignal("pre_publish") course_published = SwitchedSignal("course_published") course_deleted = SwitchedSignal("course_deleted") + course_cache_updated = SwitchedSignal("course_cache_updated") library_updated = SwitchedSignal("library_updated") item_deleted = SwitchedSignal("item_deleted") _mapping = { signal.name: signal for signal - in [pre_publish, course_published, course_deleted, library_updated, item_deleted] + in [pre_publish, course_published, course_deleted, course_cache_updated, library_updated, item_deleted] } def __init__(self, modulestore_class):