From 6d4dd70490cbd2900724b618e454afa79221dc42 Mon Sep 17 00:00:00 2001 From: Vladyslav Zherebkin Date: Tue, 14 Sep 2021 15:44:37 +0200 Subject: [PATCH] feat: [ICNC-597] Added downloading functionality for HTML (cherry picked from commit 15ef236cdc3eb05da120449b8831b0d77aebb5bf) --- .../features/_mobile_extensions/__init__.py | 0 openedx/features/_mobile_extensions/apps.py | 15 +++ .../_mobile_extensions/html_block/__init__.py | 0 .../html_block/mobile_api_module.py | 125 ++++++++++++++++++ .../features/_mobile_extensions/signals.py | 11 ++ openedx/features/_mobile_extensions/tasks.py | 12 ++ setup.py | 6 + xmodule/html_block.py | 3 +- 8 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 openedx/features/_mobile_extensions/__init__.py create mode 100644 openedx/features/_mobile_extensions/apps.py create mode 100644 openedx/features/_mobile_extensions/html_block/__init__.py create mode 100644 openedx/features/_mobile_extensions/html_block/mobile_api_module.py create mode 100644 openedx/features/_mobile_extensions/signals.py create mode 100644 openedx/features/_mobile_extensions/tasks.py diff --git a/openedx/features/_mobile_extensions/__init__.py b/openedx/features/_mobile_extensions/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/_mobile_extensions/apps.py b/openedx/features/_mobile_extensions/apps.py new file mode 100644 index 000000000000..906039ab5c2c --- /dev/null +++ b/openedx/features/_mobile_extensions/apps.py @@ -0,0 +1,15 @@ +import logging + +from django.apps import AppConfig + + +log = logging.getLogger(__name__) + + +class MobileExtensionsConfig(AppConfig): + name = 'openedx.features._mobile_extensions' + verbose_name = 'Mobile Extensions' + + def ready(self): + # Import signals to activate signal handler + from . import signals # pylint: disable=unused-variable diff --git a/openedx/features/_mobile_extensions/html_block/__init__.py b/openedx/features/_mobile_extensions/html_block/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py new file mode 100644 index 000000000000..d59775c53a92 --- /dev/null +++ b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py @@ -0,0 +1,125 @@ +import re +import zipfile + +from bs4 import BeautifulSoup +from django.conf import settings +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage + +from xmodule.assetstore.assetmgr import AssetManager +from xmodule.contentstore.content import StaticContent +from xmodule.exceptions import NotFoundError +from xmodule.modulestore.exceptions import ItemNotFoundError + + +class HtmlBlockMobileApiMixin: + FILE_NAME = 'content_html.zip' + + def update_info_api(self): + if not self.is_modified(): + return + + base_path = self._base_storage_path() + self.remove_old_files(base_path) + + def replace_static_links(match): + link = match.group() + filename = link.split('/static/')[-1] + self.save_asset_file(link, filename) + return f'assets/{filename}' + + def replace_iframe(data): + soup = BeautifulSoup(data, 'html.parser') + for node in soup.find_all('iframe'): + replacement = soup.new_tag('p') + tag_a = soup.new_tag('a') + tag_a['href'] = node.get('src') + tag_a.string = node.get('title', node.get('src')) + replacement.append(tag_a) + node.replace_with(replacement) + return str(soup) + + pattern = re.compile(r'/static/[\w\+@\-_\.]+') + data = pattern.sub(replace_static_links, self.data) + data = replace_iframe(data) + + default_storage.save(f'{base_path}index.html', ContentFile(data)) + self.create_zip_file(base_path) + + def remove_old_files(self, base_path): + try: + directories, files = default_storage.listdir(base_path) + except OSError: + pass + else: + for file_name in files: + default_storage.delete(base_path + file_name) + + 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) + + def _base_storage_path(self): + return '{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}/'.format(loc=self.location) + + def save_asset_file(self, path, filename): + asset_key = StaticContent.get_asset_key_from_path(self.location.course_key, path) + try: + content = AssetManager.find(asset_key) + except (ItemNotFoundError, NotFoundError): + pass + else: + base_path = self._base_storage_path() + default_storage.save(f'{base_path}assets/{filename}', ContentFile(content.data)) + + def create_zip_file(self, base_path): + zf = zipfile.ZipFile(default_storage.path(base_path + self.FILE_NAME), "w") + zf.write(default_storage.path(base_path + "index.html"), "index.html") + + try: + directories, files = default_storage.listdir(base_path + 'assets/') + except OSError: + pass + else: + for file_name in files: + zf.write(default_storage.path(base_path + 'assets/' + file_name), 'assets/' + file_name) + + zf.close() + + def is_modified(self): + file_path = f'{self._base_storage_path()}{self.FILE_NAME}' + + try: + last_modified = default_storage.get_created_time(file_path) + except OSError: + return True + + return self.published_on > last_modified + + def student_view_data(self): + file_path = f'{self._base_storage_path()}{self.FILE_NAME}' + + try: + default_storage.get_created_time(file_path) + except OSError: + self.update_info_api() + + html_data = default_storage.url(file_path) + + if not html_data.startswith('http'): + html_data = f'{settings.LMS_ROOT_URL}{html_data}' + + last_modified = default_storage.get_created_time(file_path) + size = default_storage.size(file_path) + + return { + 'last_modified': last_modified, + 'html_data': html_data, + 'size': size, + 'index_page': 'index.html', + 'icon_class': self.icon_class, + } diff --git a/openedx/features/_mobile_extensions/signals.py b/openedx/features/_mobile_extensions/signals.py new file mode 100644 index 000000000000..f4d1d68f8a50 --- /dev/null +++ b/openedx/features/_mobile_extensions/signals.py @@ -0,0 +1,11 @@ +import six +from django.dispatch import receiver + +from xmodule.modulestore.django import SignalHandler + +from .tasks import update_html_block_mobile_api + + +@receiver(SignalHandler.course_published) +def listen_for_course_publish(sender, course_key, **kwargs): + update_html_block_mobile_api.delay(six.text_type(course_key)) diff --git a/openedx/features/_mobile_extensions/tasks.py b/openedx/features/_mobile_extensions/tasks.py new file mode 100644 index 000000000000..04c6d1d48647 --- /dev/null +++ b/openedx/features/_mobile_extensions/tasks.py @@ -0,0 +1,12 @@ +from celery.task import task +from opaque_keys.edx.keys import CourseKey + +from xmodule.modulestore.django import modulestore + + +@task +def update_html_block_mobile_api(course_id): + course_key = CourseKey.from_string(course_id) + + for xblock_html in modulestore().get_items(course_key, qualifiers={'category': 'html'}): + xblock_html.update_info_api() diff --git a/setup.py b/setup.py index 188072354fd2..830a2362af87 100644 --- a/setup.py +++ b/setup.py @@ -154,6 +154,9 @@ "program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig", "courseware_api = openedx.core.djangoapps.courseware_api.apps:CoursewareAPIConfig", "course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig", + # [RG]: + "_mobile_extensions = openedx.features._mobile_extensions.apps:MobileExtensionsConfig", + # :[RG] ], "cms.djangoapp": [ "announcements = openedx.features.announcements.apps:AnnouncementsConfig", @@ -177,6 +180,9 @@ "user_authn = openedx.core.djangoapps.user_authn.apps:UserAuthnConfig", "instructor = lms.djangoapps.instructor.apps:InstructorConfig", "course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig", + # [RG]: + "_mobile_extensions = openedx.features._mobile_extensions.apps:MobileExtensionsConfig", + # :[RG] ], 'openedx.learning_context': [ 'lib = openedx.core.djangoapps.content_libraries.library_context:LibraryContextImpl', diff --git a/xmodule/html_block.py b/xmodule/html_block.py index 6c883e1322d2..1f0305a22fab 100644 --- a/xmodule/html_block.py +++ b/xmodule/html_block.py @@ -11,6 +11,7 @@ from django.conf import settings from fs.errors import ResourceNotFound from lxml import etree +from openedx.features._mobile_extensions.html_block.mobile_api_module import HtmlBlockMobileApiMixin from path import Path as path from web_fragments.fragment import Fragment from xblock.core import XBlock @@ -353,7 +354,7 @@ def index_dictionary(self): @edxnotes -class HtmlBlock(HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method +class HtmlBlock(HtmlBlockMobileApiMixin, HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method """ This is the actual HTML XBlock. Nothing extra is required; this is just a wrapper to include edxnotes support.