diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 332517cc13b2..719e32b3ceaf 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -21,10 +21,10 @@ send_thread_created_notification, send_response_endorsed_notifications ) -from lms.djangoapps.mobile_api.offline_mode.tasks import generate_course_media from openedx.core.djangoapps.django_comment_common import signals from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.theming.helpers import get_current_site +from lms.djangoapps.offline_mode.utils.xblock_helpers import get_xblock_view_response, generate_request_with_service_user log = logging.getLogger(__name__) @@ -48,8 +48,12 @@ def update_discussions_on_course_publish(sender, course_key, **kwargs): # pylin args=[context], countdown=settings.DISCUSSION_SETTINGS['COURSE_PUBLISH_TASK_DELAY'], ) - # import pdb; pdb.set_trace() - generate_course_media(six.text_type(course_key)) + + import pdb; + pdb.set_trace() + request = generate_request_with_service_user() + result = get_xblock_view_response(request, 'block-v1:new+123+new+type@problem+block@f7693d5dde094f65a28485582125936d', 'student_view') + print(result) @receiver(signals.comment_created) diff --git a/lms/djangoapps/mobile_api/apps.py b/lms/djangoapps/mobile_api/apps.py index c7416966c086..2e7cb30990d3 100644 --- a/lms/djangoapps/mobile_api/apps.py +++ b/lms/djangoapps/mobile_api/apps.py @@ -12,10 +12,3 @@ class MobileApiConfig(AppConfig): """ name = 'lms.djangoapps.mobile_api' verbose_name = "Mobile API" - - def ready(self): - """ - Connect signal handlers. - """ - from lms.djangoapps.mobile_api.offline_mode import signals # pylint: disable=unused-import - from lms.djangoapps.mobile_api.offline_mode import tasks # pylint: disable=unused-import diff --git a/lms/djangoapps/offline_mode/handlers.py b/lms/djangoapps/offline_mode/handlers.py index 06eed7d28c75..603cd88b0117 100644 --- a/lms/djangoapps/offline_mode/handlers.py +++ b/lms/djangoapps/offline_mode/handlers.py @@ -2,13 +2,18 @@ from django.dispatch import receiver from openedx_events.content_authoring.signals import XBLOCK_PUBLISHED + from xmodule.modulestore.django import SignalHandler from .tasks import generate_course_media +from .utils.assets_management import remove_old_files @receiver([XBLOCK_PUBLISHED]) -def listen_course_publish(**kwargs): - if USER_TOURS_DISABLED.is_disabled(): +def listen_xblock_publish(**kwargs): + if MEDIA_GENERATION_ENABLED.is_disabled(): return - generate_course_media.delay(six.text_type(course_key)) + usage_key = UsageKey.from_string(kwargs.get('usage_key_string')) + xblock = modulestore().get_item(usage_key) + remove_old_files(xblock) + diff --git a/lms/djangoapps/offline_mode/tests/test_signals.py b/lms/djangoapps/offline_mode/tests/test_signals.py index d2f39ebc9ef5..25ef1d5fcbad 100644 --- a/lms/djangoapps/offline_mode/tests/test_signals.py +++ b/lms/djangoapps/offline_mode/tests/test_signals.py @@ -9,25 +9,21 @@ # Mocking the XBLOCK_PUBLISHED signal XBLOCK_PUBLISHED = Signal(providing_args=["course_key"]) -class ListenCoursePublishSignalTest(TestCase): +class ListenXBlockPublishSignalTest(TestCase): def setUp(self): - self.course_key = 'course-v1:edX+DemoX+Demo_Course' + self.usage_key = '' @patch('myapp.signals.generate_course_media.delay') @patch('myapp.signals.USER_TOURS_DISABLED.is_disabled', return_value=False) def test_listen_course_publish_signal_handler(self, mock_is_disabled, mock_generate_course_media): - # Simulate sending the signal - XBLOCK_PUBLISHED.send(sender=None, course_key=self.course_key) + XBLOCK_PUBLISHED.send(sender=None, course_key=self.usage_key) - # Check if the generate_course_media task was called with the correct arguments - mock_generate_course_media.assert_called_once_with(self.course_key) + mock_generate_course_media.assert_called_once_with(self.usage_key) @patch('myapp.signals.generate_course_media.delay') @patch('myapp.signals.USER_TOURS_DISABLED.is_disabled', return_value=True) def test_listen_course_publish_signal_handler_disabled(self, mock_is_disabled, mock_generate_course_media): - # Simulate sending the signal - XBLOCK_PUBLISHED.send(sender=None, course_key=self.course_key) + XBLOCK_PUBLISHED.send(sender=None, course_key=self.usage_key) - # Check that the generate_course_media task was not called since the feature is disabled mock_generate_course_media.assert_not_called() diff --git a/lms/djangoapps/offline_mode/tests/test_zip_management.py b/lms/djangoapps/offline_mode/tests/test_zip_management.py index e69de29bb2d1..8768cda5dce9 100644 --- a/lms/djangoapps/offline_mode/tests/test_zip_management.py +++ b/lms/djangoapps/offline_mode/tests/test_zip_management.py @@ -0,0 +1,45 @@ +import unittest +from unittest.mock import Mock, patch, call +import zipfile + +from lms.djangoapps.offline_mode.utils.zip_management import create_zip_file + + +class CreateZipFileTest(unittest.TestCase): + + @patch('your_module.default_storage') + @patch('your_module.zipfile.ZipFile') + def test_create_zip_file(self, mock_zipfile, mock_default_storage): + # Setup mock paths + base_path = 'test_base_path/' + file_name = 'test_file.zip' + index_html_path = f'{base_path}index.html' + assets_path = f'{base_path}assets/' + asset_file_path = f'{assets_path}test_asset.txt' + + # Mock default_storage behavior + mock_default_storage.path.side_effect = lambda x: x + mock_default_storage.listdir.side_effect = [ + (['assets'], ['index.html']), # Root directory + ([], ['test_asset.txt']) # Assets directory + ] + + # Mock zipfile behavior + mock_zf_instance = Mock() + mock_zipfile.return_value = mock_zf_instance + + # Call the function to test + create_zip_file(base_path, file_name) + + # Assertions + mock_zipfile.assert_called_once_with(f'{base_path}{file_name}', 'w') + mock_zf_instance.write.assert_any_call(index_html_path, 'index.html') + mock_zf_instance.write.assert_any_call(asset_file_path, 'assets/test_asset.txt') + mock_zf_instance.close.assert_called_once() + + expected_calls = [ + call(path=f'{base_path}index.html'), + call(path=f'{assets_path}'), + ] + self.assertEqual(mock_default_storage.path.call_count, 2) + mock_default_storage.path.assert_has_calls(expected_calls, any_order=True) diff --git a/lms/djangoapps/offline_mode/urls.py b/lms/djangoapps/offline_mode/urls.py new file mode 100644 index 000000000000..bf1b513c8abb --- /dev/null +++ b/lms/djangoapps/offline_mode/urls.py @@ -0,0 +1,12 @@ +""" +URLs for mobile API +""" + + +from django.urls import include, path + +from .views import OfflineXBlockStatusInfoView + +urlpatterns = [ + path('xblocks_status_info/', OfflineXBlockStatusInfoView.as_view(), name='offline_xblocks_info'), +] diff --git a/lms/djangoapps/offline_mode/utils/assets_management.py b/lms/djangoapps/offline_mode/utils/assets_management.py index 7edb34ff4ff3..2950764bb6f2 100644 --- a/lms/djangoapps/offline_mode/utils/assets_management.py +++ b/lms/djangoapps/offline_mode/utils/assets_management.py @@ -1,4 +1,6 @@ +import shutil import os +import logging from django.core.files.base import ContentFile from django.core.files.storage import default_storage @@ -10,6 +12,9 @@ from .file_management import get_static_file_path, read_static_file +log = logging.getLogger(__name__) + + def save_asset_file(xblock, path, filename): """ Saves an asset file to the default storage. @@ -18,7 +23,7 @@ def save_asset_file(xblock, path, filename): Otherwise, it fetches the asset from the AssetManager. Args: - xblock (XBlock): The XBlock instance that provides context for the file. + xblock (XBlock): The XBlock instance path (str): The path where the asset is located. filename (str): The name of the file to be saved. """ @@ -36,28 +41,66 @@ def save_asset_file(xblock, path, filename): default_storage.save(f'{base_path}assets/{filename}', ContentFile(content)) -def remove_old_files(base_path): +def remove_old_files(xblock): """ - Removes old files from the specified base path and its 'assets/' subdirectory. + Removes the 'asset' directory, 'index.html', and 'offline_content.zip' files + in the specified base path directory. Args: - base_path (str): The base path from which to delete files. + (XBlock): The XBlock instance """ try: - directories, files = default_storage.listdir(base_path) - except OSError: - pass - else: - for file_name in files: - default_storage.delete(base_path + file_name) + base_path = base_storage_path(xblock) + + # Define the paths to the specific items to delete + asset_path = os.path.join(base_path, 'asset') + index_file_path = os.path.join(base_path, 'index.html') + offline_zip_path = os.path.join(base_path, 'offline_content.zip') + + # Delete the 'asset' directory if it exists + if os.path.isdir(asset_path): + shutil.rmtree(asset_path) + log.info(f"Successfully deleted the directory: {asset_path}") + + # Delete the 'index.html' file if it exists + if os.path.isfile(index_file_path): + os.remove(index_file_path) + log.info(f"Successfully deleted the file: {index_file_path}") + + # Delete the 'offline_content.zip' file if it exists + if os.path.isfile(offline_zip_path): + os.remove(offline_zip_path) + log.info(f"Successfully deleted the file: {offline_zip_path}") + + except Exception as e: + log.error(f"Error occurred while deleting the files or directory: {e}") + + +def is_offline_content_present(xblock): + """ + Checks whether 'offline_content.zip' file is present in the specified base path directory. + + Args: + xblock (XBlock): The XBlock instance + Returns: + bool: True if the file is present, False otherwise + """ try: - directories, files = default_storage.listdir(base_path + 'assets/') - except OSError: - pass - else: - for file_name in files: - default_storage.delete(base_path + 'assets/' + file_name) + base_path = base_storage_path(xblock) + + # Define the path to the 'offline_content.zip' file + offline_zip_path = os.path.join(base_path, 'offline_content.zip') + + # Check if the file exists + if os.path.isfile(offline_zip_path): + return True + else: + return False + + except Exception as e: + log.error(f"Error occurred while checking the file: {e}") + return False def base_storage_path(xblock): diff --git a/lms/djangoapps/offline_mode/utils/xblock_helpers.py b/lms/djangoapps/offline_mode/utils/xblock_helpers.py index e1c7ae26fe74..5041a552c75e 100644 --- a/lms/djangoapps/offline_mode/utils/xblock_helpers.py +++ b/lms/djangoapps/offline_mode/utils/xblock_helpers.py @@ -1,7 +1,9 @@ +from django.urls import reverse, resolve from django.conf import settings from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.core.files.storage import default_storage +from django.contrib.sessions.backends.db import SessionStore from django.http import HttpRequest from xmodule.modulestore.django import modulestore @@ -34,9 +36,189 @@ def generate_request_with_service_user(): user = User.objects.get(email='edx@example.com') request = HttpRequest() request.user = user + # Set up the session + session = SessionStore() + session.create() + request.session = session + return request +def cms_xblock_view_handler(usage_key_string, view_name): + # Generate the URL for the view + url = reverse('xblock_view_handler', kwargs={'usage_key_string': usage_key_string, 'view_name': view_name}) + + # Create a mock request object + request = generate_request_with_service_user() + request.method = 'GET' + request.META['HTTP_ACCEPT'] = 'application/json' + + # Resolve the URL to get the view function + view_func, args, kwargs = resolve(url) + + try: + # Call the view function with the request and resolved kwargs + response = view_func(request, *args, **kwargs) + except Exception as e: + return None + + return response + + +def get_xblock_view_response(request, usage_key_string, view_name): + from collections import OrderedDict + from functools import partial + + from django.utils.translation import gettext as _ + from web_fragments.fragment import Fragment + + from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW + from common.djangoapps.edxmako.shortcuts import render_to_string + from common.djangoapps.student.auth import ( + has_studio_read_access, + has_studio_write_access, + ) + from openedx.core.lib.xblock_utils import ( + hash_resource, + request_token, + wrap_xblock, + wrap_xblock_aside, + ) + from xmodule.modulestore.django import modulestore + from cms.djangoapps.contentstore.toggles import use_tagging_taxonomy_list_page + + from xmodule.x_module import ( + AUTHOR_VIEW, + PREVIEW_VIEWS, + STUDENT_VIEW, + STUDIO_VIEW, + ) + + from cms.djangoapps.contentstore.helpers import is_unit + from cms.djangoapps.contentstore.views.preview import get_preview_fragment + from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import ( + usage_key_with_run, + get_children_tags_count, + ) + + usage_key = usage_key_with_run(usage_key_string) + if not has_studio_read_access(request.user, usage_key.course_key): + return None + + accept_header = request.META.get("HTTP_ACCEPT", "application/json") + + if "application/json" in accept_header: + store = modulestore() + xblock = store.get_item(usage_key) + container_views = [ + "container_preview", + "reorderable_container_child_preview", + "container_child_preview", + ] + + xblock.runtime.wrappers.append( + partial( + wrap_xblock, + "StudioRuntime", + usage_id_serializer=str, + request_token=request_token(request), + ) + ) + + xblock.runtime.wrappers_asides.append( + partial( + wrap_xblock_aside, + "StudioRuntime", + usage_id_serializer=str, + request_token=request_token(request), + extra_classes=["wrapper-comp-plugins"], + ) + ) + + if view_name in (STUDIO_VIEW, VISIBILITY_VIEW): + if view_name == STUDIO_VIEW: + load_services_for_studio(xblock.runtime, request.user) + + try: + fragment = xblock.render(view_name) + except Exception as exc: + log.debug( + "Unable to render %s for %r", view_name, xblock, exc_info=True + ) + fragment = Fragment( + render_to_string("html_error.html", {"message": str(exc)}) + ) + + elif view_name in PREVIEW_VIEWS + container_views: + is_pages_view = view_name == STUDENT_VIEW + can_edit = has_studio_write_access(request.user, usage_key.course_key) + + reorderable_items = set() + if view_name == "reorderable_container_child_preview": + reorderable_items.add(xblock.location) + + paging = None + try: + if request.GET.get("enable_paging", "false") == "true": + paging = { + "page_number": int(request.GET.get("page_number", 0)), + "page_size": int(request.GET.get("page_size", 0)), + } + except ValueError: + return None + + force_render = request.GET.get("force_render", None) + + tags_count_map = {} + if use_tagging_taxonomy_list_page(): + tags_count_map = get_children_tags_count(xblock) + + context = request.GET.dict() + context.update( + { + "is_pages_view": is_pages_view or view_name == AUTHOR_VIEW, + "is_unit_page": is_unit(xblock), + "can_edit": can_edit, + "root_xblock": xblock if (view_name == "container_preview") else None, + "reorderable_items": reorderable_items, + "paging": paging, + "force_render": force_render, + "item_url": "/container/{usage_key}", + "tags_count_map": tags_count_map, + } + ) + fragment = get_preview_fragment(request, xblock, context) + + display_label = xblock.display_name or xblock.scope_ids.block_type + if not xblock.display_name and xblock.scope_ids.block_type == "html": + display_label = _("Text") + if is_pages_view: + fragment.content = render_to_string( + "component.html", + { + "xblock_context": context, + "xblock": xblock, + "locator": usage_key, + "preview": fragment.content, + "label": display_label, + }, + ) + else: + return None + + hashed_resources = OrderedDict() + for resource in fragment.resources: + hashed_resources[hash_resource(resource)] = resource._asdict() + + fragment_content = fragment.content + if isinstance(fragment_content, bytes): + fragment_content = fragment.content.decode("utf-8") + + return {"html": fragment_content, "resources": list(hashed_resources.items())} + + return None + + def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_debug_info=False): """ Helper function to render an XBlock and return the rendered HTML content. @@ -149,9 +331,9 @@ def generate_offline_content(xblock, html_data): return base_path = base_storage_path(xblock) - remove_old_files(base_path) + remove_old_files(xblock) html_manipulator = HtmlManipulator(xblock, html_data) updated_html = html_manipulator.process_html() default_storage.save(f'{base_path}index.html', ContentFile(updated_html)) - create_zip_file(base_path, 'content_html.zip') + create_zip_file(base_path, 'offline_content.zip') diff --git a/lms/djangoapps/offline_mode/views.py b/lms/djangoapps/offline_mode/views.py new file mode 100644 index 000000000000..9626fc4a332e --- /dev/null +++ b/lms/djangoapps/offline_mode/views.py @@ -0,0 +1,36 @@ +from rest_framework.views import APIView + +from .tasks import generate_course_media + + +class OfflineXBlockStatusInfoView(APIView): + + def get(self, request, course_id): + course_key = CourseKey.from_string(course_id) + response_data = [] + + for xblock in modulestore().get_items(course_key, qualifiers={'category': 'problem'}): + if not is_offline_supported(xblock): + continue + if not is_offline_content_present(xblock): + generate_course_media.delay(course_id) + return Response({'status': False, 'data': []}) + + base_path = base_storage_path(xblock) + offline_zip_path = os.path.join(base_path, 'offline_content.zip') + + html_data = default_storage.url(offline_zip_path) + if not html_data.startswith('http'): + html_data = f'{settings.LMS_ROOT_URL}{html_data}' + + last_modified = default_storage.get_created_time(offline_zip_path) + size = default_storage.size(offline_zip_path) + + response_data.append({ + 'link': html_data, + 'file_size': size, + 'last_modified': last_modified, + }) + + return Response({'status': True, 'data': response_data}) + diff --git a/lms/urls.py b/lms/urls.py index 15e374dd8551..43abf26f4a18 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -221,6 +221,10 @@ re_path(r'^api/mobile/(?Pv(4|3|2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')), ] +urlpatterns += [ + re_path(r'^api/offline_mode/', include('lms.djangoapps.offline_mode.urls')), +] + urlpatterns += [ path('openassessment/fileupload/', include('openassessment.fileupload.urls')), ]