diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index d756424bccaa..d04a28a1d71d 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -5,6 +5,7 @@ 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 @@ -21,12 +22,14 @@ CoursewareSearchIndexer, LibrarySearchIndexer, ) +from cms.djangoapps.contentstore.utils import get_cms_api_client from common.djangoapps.track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type from common.djangoapps.util.block_utils import yield_dynamic_block_descendants from lms.djangoapps.grades.api import task_compute_all_grades_for_course 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,14 @@ 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): + client = get_cms_api_client() + client.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/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index c0f656ec7059..eef2b27f4355 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -6,6 +6,7 @@ import html import logging import re +import requests from collections import defaultdict from contextlib import contextmanager from datetime import datetime, timezone @@ -14,8 +15,10 @@ from bs4 import BeautifulSoup from django.conf import settings +from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.urls import reverse +from edx_rest_api_client.auth import SuppliedJwtAuth from django.utils import translation from django.utils.text import Truncator from django.utils.translation import gettext as _ @@ -96,6 +99,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.models.course_details import CourseDetails +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.lib.courses import course_image_url from openedx.core.lib.html_to_text import html_to_text from openedx.features.content_type_gating.models import ContentTypeGatingConfig @@ -113,6 +117,7 @@ IMPORTABLE_FILE_TYPES = ('.tar.gz', '.zip') log = logging.getLogger(__name__) +User = get_user_model() def add_instructor(course_key, requesting_user, new_instructor): @@ -2344,3 +2349,15 @@ def get_xblock_render_context(request, block): return str(exc) return "" + + +def get_cms_api_client(): + """ + Returns an API client which can be used to make requests from the CMS service. + """ + user = User.objects.get(username=settings.CMS_SERVICE_USER_NAME) + jwt = create_jwt_for_user(user) + client = requests.Session() + client.auth = SuppliedJwtAuth(jwt) + + return client diff --git a/cms/envs/common.py b/cms/envs/common.py index 00a384a359c6..c6eda1c1fffa 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2523,6 +2523,8 @@ EXAMS_SERVICE_URL = 'http://localhost:18740/api/v1' EXAMS_SERVICE_USERNAME = 'edx_exams_worker' +CMS_SERVICE_USER_NAME = 'edxapp_cms_worker' + FINANCIAL_REPORTS = { 'STORAGE_TYPE': 'localfs', 'BUCKET': None, diff --git a/cms/envs/production.py b/cms/envs/production.py index ad7667772f9a..1b1e8fc6f876 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -168,6 +168,7 @@ def get_env_setting(setting): ENTERPRISE_CONSENT_API_URL = ENV_TOKENS.get('ENTERPRISE_CONSENT_API_URL', LMS_INTERNAL_ROOT_URL + '/consent/api/v1/') AUTHORING_API_URL = ENV_TOKENS.get('AUTHORING_API_URL', '') # Note that FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file. +CMS_SERVICE_USER_NAME = ENV_TOKENS.get('CMS_SERVICE_USER_NAME', CMS_SERVICE_USER_NAME) CHAT_COMPLETION_API = ENV_TOKENS.get('CHAT_COMPLETION_API', '') CHAT_COMPLETION_API_KEY = ENV_TOKENS.get('CHAT_COMPLETION_API_KEY', '') 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..979f7df24d2c 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,9 +20,11 @@ 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 openedx.features.offline_mode.constants import DEFAULT_OFFLINE_SUPPORTED_XBLOCKS +from openedx.features.offline_mode.toggles import ENABLE_OFFLINE_MODE from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -430,6 +432,87 @@ 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 + + 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) + + @override_waffle_flag(ENABLE_OFFLINE_MODE, active=True) + @patch('openedx.features.offline_mode.html_manipulator.save_mathjax_to_xblock_assets') + def test_create_offline_content_integration_test(self, save_mathjax_to_xblock_assets_mock: MagicMock) -> None: + UserFactory.create(username='offline_mode_worker', password='password', is_staff=True) + handle_course_published_url = reverse('offline_mode:handle_course_published') + self.client.login(username='offline_mode_worker', password='password') + + handler_response = self.client.post(handle_course_published_url, {'course_id': str(self.course.id)}) + self.assertEqual(handler_response.status_code, status.HTTP_200_OK) + + url = reverse('blocks_info_in_course', kwargs={'api_version': API_V4}) + + response = self.verify_response(url=url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for block_info in response.data['blocks'].values(): + if block_type := block_info.get('type'): + if block_type in DEFAULT_OFFLINE_SUPPORTED_XBLOCKS: + expected_offline_content_url = f'/uploads/{self.course.id}/{block_info["block_id"]}.zip' + self.assertIn('offline_download', block_info) + self.assertIn('file_url', block_info['offline_download']) + self.assertIn('last_modified', block_info['offline_download']) + self.assertIn('file_size', block_info['offline_download']) + self.assertEqual(expected_offline_content_url, block_info['offline_download']['file_url']) + class TestCourseEnrollmentDetailsView(MobileAPITestCase, MilestonesTestCaseMixin): # lint-amnesty, pylint: disable=test-inherits-tests """ diff --git a/lms/envs/common.py b/lms/envs/common.py index d74c28e75687..f73147417125 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3330,6 +3330,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/static/js/courseware/bridge.js b/lms/static/js/courseware/bridge.js new file mode 100644 index 000000000000..33d22f2999d1 --- /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 formattedData = options.data.replace(/\+/g, '%20'); + let jsonMessage = JSON.stringify(formattedData) + + sendMessageToIOS(jsonMessage) + sendMessageToAndroid(jsonMessage) + } + } + 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..079a77445df7 --- /dev/null +++ b/lms/static/js/spec/courseware/bridge_spec.js @@ -0,0 +1,99 @@ +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..e386d10e2b57 --- /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, '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 = '
' + 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 = ( + '' + ) + + self.assertEqual(html_manipulator.html_data, html_data_mock) + + html_manipulator._replace_static_links() # lint-amnesty, pylint: disable=protected-access + + save_asset_file_mock.assert_called_once_with( + html_manipulator.temp_dir, + html_manipulator.xblock, + '/static/images/professor-sandel.jpg', + 'images/professor-sandel.jpg', + ) + self.assertEqual(html_manipulator.html_data, expected_html_data_after_replacing) + + @patch('openedx.features.offline_mode.html_manipulator.save_asset_file') + def test_replace_asset_links(self, save_asset_file_mock: MagicMock) -> None: + html_data_mock = '/assets/courseware/v1/5b628a18f2ee3303081ffe4d6ab64ee4/asset-v1:OpenedX+DemoX+DemoCourse+type@asset+block/Pendleton_Sinking_Ship.jpeg' # lint-amnesty, pylint: disable=line-too-long + 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 = ( + 'assets/courseware/v1/5b628a18f2ee3303081ffe4d6ab64ee4/asset-v1:OpenedX+DemoX+DemoCourse+type@asset+block/Pendleton_Sinking_Ship.jpeg' # lint-amnesty, pylint: disable=line-too-long + ) + + self.assertEqual(html_manipulator.html_data, html_data_mock) + + html_manipulator._replace_asset_links() # lint-amnesty, pylint: disable=protected-access + + save_asset_file_mock.assert_called_once_with( + html_manipulator.temp_dir, + html_manipulator.xblock, + html_data_mock, + expected_html_data_after_replacing, + ) + self.assertEqual(html_manipulator.html_data, expected_html_data_after_replacing) + + def test_replace_iframe(self): + html_data_mock = """ + + """ + soup = BeautifulSoup(html_data_mock, 'html.parser') + expected_html_markup = """""" + + HtmlManipulator._replace_iframe(soup) # lint-amnesty, pylint: disable=protected-access + + self.assertEqual(f'{soup.find_all("p")[0]}', expected_html_markup) + + @patch('openedx.features.offline_mode.html_manipulator.save_external_file') + def test_replace_external_links(self, save_external_file_mock: MagicMock) -> None: + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_data_mock = """ + + + """ + + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + html_manipulator._replace_external_links() # lint-amnesty, pylint: disable=protected-access + + self.assertEqual(save_external_file_mock.call_count, 2) + + @patch('openedx.features.offline_mode.html_manipulator.uuid.uuid4') + @patch('openedx.features.offline_mode.html_manipulator.save_external_file') + def test_replace_external_link( + self, + save_external_file_mock: MagicMock, + uuid_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_data_mock = 'html_data_mock' + external_url_mock = 'https://cdn.example.com/image.jpg' + uuid_result_mock = '123e4567-e89b-12d3-a456-426655440000' + uuid_mock.return_value = uuid_result_mock + mock_match = MagicMock() + mock_match.group.side_effect = [external_url_mock, 'jpg'] + + expected_result = 'assets/external/123e4567-e89b-12d3-a456-426655440000.jpg' + expected_save_external_file_args = [call(temp_dir_mock, external_url_mock, expected_result)] + + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + result = html_manipulator._replace_external_link(mock_match) # lint-amnesty, pylint: disable=protected-access + + self.assertListEqual(save_external_file_mock.call_args_list, expected_save_external_file_args) + self.assertEqual(result, expected_result) diff --git a/openedx/features/offline_mode/tests/test_renderer.py b/openedx/features/offline_mode/tests/test_renderer.py new file mode 100644 index 000000000000..b46d75431cf4 --- /dev/null +++ b/openedx/features/offline_mode/tests/test_renderer.py @@ -0,0 +1,31 @@ +""" +Tests for the testing xBlock renderers for Offline Mode. +""" + +from openedx.features.offline_mode.renderer import XBlockRenderer +from openedx.features.offline_mode.tests.base import CourseForOfflineTestCase + + +class XBlockRendererTestCase(CourseForOfflineTestCase): + """ + Test case for the testing `XBlockRenderer`. + """ + + def test_render_xblock_from_lms_html_block(self): + xblock_renderer = XBlockRenderer(str(self.html_block.location), user=self.user) + + result = xblock_renderer.render_xblock_from_lms() + + self.assertIsNotNone(result) + self.assertEqual(type(result), str) + self.assertIn('HTML xblock for Offline', result) + self.assertIn('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..f77f2ba614cd --- /dev/null +++ b/openedx/features/offline_mode/tests/test_storage_management.py @@ -0,0 +1,268 @@ +""" +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/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/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..ef468d825d7b --- /dev/null +++ b/openedx/features/offline_mode/tests/test_tasks.py @@ -0,0 +1,141 @@ +""" +Tests for the testing Offline Mode tacks. +""" + +from unittest import TestCase +from unittest.mock import MagicMock, Mock, call, patch + +from ddt import data, ddt, unpack +from django.http.response import Http404 +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx.features.offline_mode.constants import MAX_RETRY_ATTEMPTS, OFFLINE_SUPPORTED_XBLOCKS +from openedx.features.offline_mode.tasks import ( + generate_offline_content_for_block, + generate_offline_content_for_course, +) + + +@ddt +class GenerateOfflineContentTasksTestCase(TestCase): + """ + Test case for the testing generating offline content tacks. + """ + + @patch('openedx.features.offline_mode.tasks.OfflineContentGenerator') + @patch('openedx.features.offline_mode.tasks.modulestore') + def test_generate_offline_content_for_block_success( + self, + modulestore_mock: MagicMock, + offline_content_generator_mock: MagicMock, + ) -> None: + block_id_mock = 'block-v1:a+a+a+type@problem+block@fb81e4dbfd4945cb9318d6bc460a956c' + + generate_offline_content_for_block(block_id_mock) + + modulestore_mock.assert_called_once_with() + modulestore_mock.return_value.get_item.assert_called_once_with(UsageKey.from_string(block_id_mock)) + offline_content_generator_mock.assert_called_once_with(modulestore_mock.return_value.get_item.return_value) + offline_content_generator_mock.return_value.generate_offline_content.assert_called_once_with() + + @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_modulestore( + self, + modulestore_mock: MagicMock, + offline_content_generator_mock: MagicMock, + ) -> None: + block_id_mock = 'block-v1:a+a+a+type@problem+block@fb81e4dbfd4945cb9318d6bc460a956c' + + generate_offline_content_for_block.delay(block_id_mock) + + self.assertEqual(modulestore_mock.call_count, MAX_RETRY_ATTEMPTS + 1) + offline_content_generator_mock.assert_not_called() + + @patch('openedx.features.offline_mode.tasks.OfflineContentGenerator', side_effect=Http404) + @patch('openedx.features.offline_mode.tasks.modulestore') + def test_generate_offline_content_for_block_with_exception_in_offline_content_generation( + self, + modulestore_mock: MagicMock, + offline_content_generator_mock: MagicMock, + ) -> None: + block_id_mock = 'block-v1:a+a+a+type@problem+block@fb81e4dbfd4945cb9318d6bc460a956c' + + generate_offline_content_for_block.delay(block_id_mock) + + self.assertEqual(modulestore_mock.call_count, MAX_RETRY_ATTEMPTS + 1) + self.assertEqual(offline_content_generator_mock.call_count, MAX_RETRY_ATTEMPTS + 1) + + @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_supported_block_types( + self, + modulestore_mock: MagicMock, + is_modified_mock: MagicMock, + generate_offline_content_for_block_mock: MagicMock, + ) -> None: + course_id_mock = 'course-v1:a+a+a' + xblock_location_mock = 'xblock_location_mock' + modulestore_mock.return_value.get_items.return_value = [ + Mock(location=xblock_location_mock, closed=Mock(return_value=False)) + ] + + expected_call_args_for_modulestore_get_items = [ + call(CourseKey.from_string(course_id_mock), qualifiers={'category': offline_supported_block_type}) + for offline_supported_block_type in OFFLINE_SUPPORTED_XBLOCKS + ] + expected_call_args_is_modified_mock = [ + call(modulestore_mock.return_value.get_items.return_value[0]) for _ in OFFLINE_SUPPORTED_XBLOCKS + ] + expected_call_args_for_generate_offline_content_for_block_mock = [ + call([xblock_location_mock]) for _ in OFFLINE_SUPPORTED_XBLOCKS + ] + + generate_offline_content_for_course(course_id_mock) + + 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 + ) + self.assertListEqual(is_modified_mock.call_args_list, expected_call_args_is_modified_mock) + self.assertListEqual( + generate_offline_content_for_block_mock.apply_async.call_args_list, + expected_call_args_for_generate_offline_content_for_block_mock + ) + + @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: + course_id_mock = 'course-v1:a+a+a' + 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(course_id_mock), qualifiers={'category': offline_supported_block_type}) + for offline_supported_block_type in OFFLINE_SUPPORTED_XBLOCKS + ] + + generate_offline_content_for_course(course_id_mock) + + 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() diff --git a/openedx/features/offline_mode/tests/test_views.py b/openedx/features/offline_mode/tests/test_views.py new file mode 100644 index 000000000000..a4478d8796ab --- /dev/null +++ b/openedx/features/offline_mode/tests/test_views.py @@ -0,0 +1,83 @@ +""" +Tests for view that handles course published event. +""" +from unittest.mock import patch + +from django.test import TestCase, RequestFactory +from django.test.client import Client +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.features.offline_mode.views import SudioCoursePublishedEventHandler + +from openedx.features.offline_mode.toggles import ENABLE_OFFLINE_MODE + + +class TestSudioCoursePublishedEventHandler(TestCase): + """ + Tests for the SudioCoursePublishedEventHandler view. + """ + + def setUp(self): + self.client = Client() + self.factory = RequestFactory() + self.view = SudioCoursePublishedEventHandler.as_view() + self.url = reverse('offline_mode:handle_course_published') + + self.user_password = 'Password1234' + self.user = UserFactory.create(password=self.user_password) + self.staff_user = UserFactory.create(is_staff=True, password=self.user_password) + + def staff_login(self): + self.client.login(username=self.staff_user.username, password=self.user_password) + + def test_unauthorized(self): + response = self.client.post(self.url, {}) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.data, {'detail': 'Authentication credentials were not provided.'}) + + def test_not_admin(self): + self.client.login(username=self.user.username, password=self.user_password) + response = self.client.post(self.url, {}) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data, {'detail': 'You do not have permission to perform this action.'}) + + @override_waffle_flag(ENABLE_OFFLINE_MODE, active=True) + @patch('openedx.features.offline_mode.views.generate_offline_content_for_course.apply_async') + def test_admin_enabled_waffle_flag(self, mock_generate_offline_content_for_course_task): + self.staff_login() + course_id = 'course-v1:edX+DemoX+Demo_Course' + response = self.client.post(self.url, {'course_id': course_id}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, None) + mock_generate_offline_content_for_course_task.assert_called_once_with(args=[course_id]) + + @override_waffle_flag(ENABLE_OFFLINE_MODE, active=False) + def test_admin_disabled_waffle_flag(self): + self.staff_login() + response = self.client.post(self.url, {'course_id': 'course-v1:edX+DemoX+Demo_Course'}) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, {'error': 'Offline mode is not enabled for this course'}) + + @override_waffle_flag(ENABLE_OFFLINE_MODE, active=True) + def test_admin_enabled_waffle_flag_no_course_id(self): + self.staff_login() + response = self.client.post(self.url, {}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, {'error': 'course_id is required'}) + + @override_waffle_flag(ENABLE_OFFLINE_MODE, active=False) + def test_admin_disabled_waffle_flag_no_course_id(self): + self.staff_login() + response = self.client.post(self.url, {}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, {'error': 'course_id is required'}) + + def test_invalid_course_id(self): + self.staff_login() + response = self.client.post(self.url, {'course_id': 'invalid_course_id'}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, {'error': 'Invalid course_id'}) 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..d44d0d738f8a --- /dev/null +++ b/openedx/features/offline_mode/urls.py @@ -0,0 +1,11 @@ +""" +URLs for the offline_mode feature. +""" +from django.urls import path + +from .views import SudioCoursePublishedEventHandler + +app_name = 'offline_mode' +urlpatterns = [ + path('handle_course_published', SudioCoursePublishedEventHandler.as_view(), name='handle_course_published'), +] diff --git a/openedx/features/offline_mode/views.py b/openedx/features/offline_mode/views.py new file mode 100644 index 000000000000..342fae92c80c --- /dev/null +++ b/openedx/features/offline_mode/views.py @@ -0,0 +1,60 @@ +""" +Views for the offline_mode app. +""" +from opaque_keys.edx.keys import CourseKey +from opaque_keys import InvalidKeyError +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import status +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from openedx.core.lib.api.authentication import BearerAuthentication +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. + """ + + authentication_classes = (JwtAuthentication, BearerAuthentication, SessionAuthentication) + permission_classes = (IsAdminUser,) + + 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 + ) + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return Response( + data={'error': 'Invalid course_id'}, + status=status.HTTP_400_BAD_REQUEST + ) + + 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, + ) diff --git a/uwsgi.ini b/uwsgi.ini new file mode 100755 index 000000000000..e69de29bb2d1