Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Niedielnitsev ivan/offline problem media #2565

Closed
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3795130
feat: [AXM-24] Update structure for course enrollments API (#2515)
KyryloKireiev Mar 19, 2024
a6ecd16
feat: [AXM-47] Add course_status field to primary object (#2517)
KyryloKireiev Mar 22, 2024
bbc5a01
feat: [AXM-40] add courses progress to enrollment endpoint (#2519)
NiedielnitsevIvan Mar 22, 2024
c2881dc
feat: [AXM-53] add assertions for primary course (#2522)
NiedielnitsevIvan Apr 2, 2024
d0cb091
feat: [AXM-200] Implement user's enrolments status API (#2530)
KyryloKireiev Apr 8, 2024
a32b144
feat: [AXM-33] create enrollments filtering by course completion stat…
NiedielnitsevIvan Apr 8, 2024
8f9affd
feat: [AXM-236] Add progress for other courses (#2536)
NiedielnitsevIvan Apr 10, 2024
ebcdde6
fix: [AXM-277] Change _get_last_visited_block_path_and_unit_name meth…
KyryloKireiev Apr 16, 2024
18f5eb8
feat: [AXM-297] Add progress to assignments in BlocksInfoInCourseView…
KyryloKireiev Apr 25, 2024
cdfa6fa
feat: [AXM-288] Change response to represent Future assignments the s…
KyryloKireiev Apr 29, 2024
97392f5
feat: [AXM-252] add settings for edx-ace push notifications (#2541)
NiedielnitsevIvan Apr 29, 2024
874e9c4
feat: [AXM-271] Add push notification event to discussions (#2548)
NiedielnitsevIvan Apr 29, 2024
47dc395
feat: [AXM-287,310,331] Change course progress calculation logic (#2553)
KyryloKireiev May 13, 2024
e5697e7
feat: [AXM-373] Add push notification event about course invitations …
NiedielnitsevIvan May 14, 2024
0d02744
refactor: [AXM-475] refactor firebase settings (#2560)
NiedielnitsevIvan May 22, 2024
4cfab2d
feat: [AXM-556] refactor discussion push notifications sending
NiedielnitsevIvan May 29, 2024
036f989
fix: fix typo
NiedielnitsevIvan May 29, 2024
b8639e3
test: [AXM-556] add topic_id to tests
NiedielnitsevIvan May 29, 2024
ed4db49
Merge pull request #2562 from raccoongang/NiedielnitsevIvan/AXM-556/f…
NiedielnitsevIvan May 29, 2024
7628201
feat: [ICNC-597] Added downloading functionality for HTML
Sep 14, 2021
8b8faf1
feat: [ICNC-597] Implemented video downloading
Sep 15, 2021
aa38b2e
feat: playing with problem v2
vzadorozhnii May 15, 2024
585cf54
feat: move logic into mobile_api
vzadorozhnii May 16, 2024
e269490
feat: last update
vzadorozhnii May 20, 2024
dbfe6a1
feat: offline mode app
vzadorozhnii May 21, 2024
074c5bd
feat: new offline mode state
vzadorozhnii May 24, 2024
81c0ec3
chore: add fixme's
NiedielnitsevIvan May 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: offline mode app
  • Loading branch information
vzadorozhnii authored and NiedielnitsevIvan committed May 29, 2024
commit dbfe6a1ceff444cca40b3cf0fb489f134b41dbad
3 changes: 0 additions & 3 deletions lms/djangoapps/mobile_api/offline_mode/__init__.py

This file was deleted.

20 changes: 0 additions & 20 deletions lms/djangoapps/mobile_api/offline_mode/signals.py

This file was deleted.

Empty file.
Empty file.
Empty file.
14 changes: 14 additions & 0 deletions lms/djangoapps/offline_mode/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import six
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


@receiver([XBLOCK_PUBLISHED])
def listen_course_publish(**kwargs):
if USER_TOURS_DISABLED.is_disabled():
return
generate_course_media.delay(six.text_type(course_key))
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@
from opaque_keys.edx.keys import CourseKey

from xmodule.modulestore.django import modulestore
from .utils.xblock_helpers import generate_offline_content, xblock_view_handler, generate_request_with_service_user
from .utils.xblock_helpers import (
generate_offline_content,
xblock_view_handler,
generate_request_with_service_user,
is_offline_supported,
)


@shared_task
def generate_course_media(course_id):
request = generate_request_with_service_user()
course_key = CourseKey.from_string(course_id)
for xblock in modulestore().get_items(course_key, qualifiers={'category': 'problem'}):
if is_offline_supported(xblock):
continue
html_data = xblock_view_handler(request, xblock)
generate_offline_content(xblock, html_data)
140 changes: 140 additions & 0 deletions lms/djangoapps/offline_mode/tests/test_assets_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import os

from unittest import TestCase
from unittest.mock import patch, MagicMock
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

from .file_management import save_asset_file, remove_old_files, base_storage_path

class TestFileManagement(TestCase):

@patch('file_management.get_static_file_path')
@patch('file_management.read_static_file')
@patch('xmodule.contentstore.content.StaticContent.get_asset_key_from_path')
@patch('xmodule.assetstore.assetmgr.AssetManager.find')
@patch('django.core.files.storage.default_storage.save')
def test_save_asset_file_with_static_file(
self,
mock_save,
mock_find,
mock_get_asset_key,
mock_read_static_file,
mock_get_static_file_path
):
xblock = MagicMock()
xblock.location.course_key = 'course-v1:edX+DemoX+Demo_Course'
path = 'path/to/asset'
filename = 'static/file/path.txt'
content = b'some content'

mock_get_static_file_path.return_value = 'static/file/path.txt'
mock_read_static_file.return_value = content

save_asset_file(xblock, path, filename)

mock_get_static_file_path.assert_called_with(filename)
mock_read_static_file.assert_called_with('static/file/path.txt')
mock_save.assert_called_with(
f'{xblock.location.org}/{xblock.location.course}'
f'/{xblock.location.block_type}/{xblock.location.block_id}/assets/{filename}',
ContentFile(content)
)

@patch('xmodule.contentstore.content.StaticContent.get_asset_key_from_path')
@patch('xmodule.assetstore.assetmgr.AssetManager.find')
@patch('django.core.files.storage.default_storage.save')
def test_save_asset_file_with_asset_manager(self, mock_save, mock_find, mock_get_asset_key):
xblock = MagicMock()
xblock.location.course_key = 'course-v1:edX+DemoX+Demo_Course'
path = 'path/to/asset'
filename = 'asset.txt'
content = b'some content'

mock_get_asset_key.return_value = 'asset_key'
mock_find.return_value.data = content

save_asset_file(xblock, path, filename)

mock_get_asset_key.assert_called_with(xblock.location.course_key, path)
mock_find.assert_called_with('asset_key')
mock_save.assert_called_with(
f'{xblock.location.org}/{xblock.location.course}'
f'/{xblock.location.block_type}/{xblock.location.block_id}/assets/{filename}',
ContentFile(content)
)

@patch('xmodule.contentstore.content.StaticContent.get_asset_key_from_path')
@patch('xmodule.assetstore.assetmgr.AssetManager.find')
@patch('file_management.get_static_file_path')
@patch('file_management.read_static_file')
@patch('django.core.files.storage.default_storage.save')
def test_save_asset_file_not_found_error(
self,
mock_save,
mock_read_static_file,
mock_get_static_file_path,
mock_find,
mock_get_asset_key
):
xblock = MagicMock()
xblock.location.course_key = 'course-v1:edX+DemoX+Demo_Course'
path = 'path/to/asset'
filename = 'asset.txt'

mock_get_asset_key.side_effect = ItemNotFoundError
mock_find.side_effect = NotFoundError

save_asset_file(xblock, path, filename)

mock_save.assert_not_called()

@patch('django.core.files.storage.default_storage.listdir')
@patch('django.core.files.storage.default_storage.delete')
def test_remove_old_files(self, mock_delete, mock_listdir):
base_path = 'base/path/'
files = ['file1.txt', 'file2.txt']
assets_files = ['asset1.txt', 'asset2.txt']

mock_listdir.side_effect = [
([], files), # for base_path
([], assets_files), # for base_path + 'assets/'
]

remove_old_files(base_path)

expected_delete_calls = [
patch('django.core.files.storage.default_storage.delete').call(base_path + 'file1.txt'),
patch('django.core.files.storage.default_storage.delete').call(base_path + 'file2.txt'),
patch('django.core.files.storage.default_storage.delete').call(base_path + 'assets/' + 'asset1.txt'),
patch('django.core.files.storage.default_storage.delete').call(base_path + 'assets/' + 'asset2.txt'),
]

mock_delete.assert_has_calls(expected_delete_calls, any_order=True)

@patch('django.core.files.storage.default_storage.listdir')
@patch('django.core.files.storage.default_storage.delete')
def test_remove_old_files_os_error(self, mock_delete, mock_listdir):
base_path = 'base/path/'

mock_listdir.side_effect = OSError

remove_old_files(base_path)

mock_delete.assert_not_called()

def test_base_storage_path(self):
xblock = MagicMock()
xblock.location.org = 'edX'
xblock.location.course = 'DemoX'
xblock.location.block_type = 'block'
xblock.location.block_id = 'block_id'

expected_path = 'edX/DemoX/block/block_id/'

self.assertEqual(base_storage_path(xblock), expected_path)
72 changes: 72 additions & 0 deletions lms/djangoapps/offline_mode/tests/test_html_manipulation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import unittest
from unittest.mock import Mock, patch
from bs4 import BeautifulSoup

from .html_manipulator import HtmlManipulator


class HtmlManipulatorTest(unittest.TestCase):

def setUp(self):
self.xblock = Mock()
self.html_data = '''
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js?config=TeX-AMS_HTML"></script>
</head>
<body>
<iframe src="https://example.com/video" title="Example Video"></iframe>
<img src="/static/img/sample.png">
</body>
</html>
'''
self.manipulator = HtmlManipulator(self.xblock, self.html_data)

@patch('html_manipulator.save_asset_file')
def test_replace_mathjax_link(self, mock_save_asset_file):
updated_html = self.manipulator._replace_mathjax_link()
self.assertNotIn('https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js', updated_html)
self.assertIn('src="/static/mathjax/MathJax.js"', updated_html)

@patch('html_manipulator.save_asset_file')
def test_replace_static_links(self, mock_save_asset_file):
updated_html = self.manipulator._replace_static_links()
self.assertIn('assets/img/sample.png', updated_html)
mock_save_asset_file.assert_called_with(self.xblock, '/static/img/sample.png', 'img/sample.png')

def test_replace_iframe(self):
soup = BeautifulSoup(self.html_data, 'html.parser')
self.manipulator._replace_iframe(soup)
self.assertEqual(len(soup.find_all('iframe')), 0)
self.assertEqual(len(soup.find_all('a', href='https://example.com/video')), 1)

def test_add_js_bridge(self):
soup = BeautifulSoup(self.html_data, 'html.parser')
self.manipulator._add_js_bridge(soup)
script_tag = soup.find('script', string=lambda text: 'sendMessageToiOS' in text if text else False)
self.assertIsNotNone(script_tag)
self.assertIn('sendMessageToAndroid', script_tag.string)

@patch('html_manipulator.save_asset_file')
def test_process_html(self, mock_save_asset_file):
final_html = self.manipulator.process_html()
soup = BeautifulSoup(final_html, 'html.parser')

# Check MathJax link replacement
mathjax_script = soup.find('script', src='/static/mathjax/MathJax.js')
self.assertIsNotNone(mathjax_script)

# Check iframe replacement
iframes = soup.find_all('iframe')
self.assertEqual(len(iframes), 0)
anchors = soup.find_all('a', href='https://example.com/video')
self.assertEqual(len(anchors), 1)

# Check static link replacement
img_tag = soup.find('img', src='assets/img/sample.png')
self.assertIsNotNone(img_tag)
mock_save_asset_file.assert_called_with(self.xblock, '/static/img/sample.png', 'img/sample.png')

# Check JS bridge script
script_tag = soup.find('script', string=lambda text: 'sendMessageToiOS' in text if text else False)
self.assertIsNotNone(script_tag)
33 changes: 33 additions & 0 deletions lms/djangoapps/offline_mode/tests/test_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import unittest
from unittest.mock import patch, Mock
from django.test import TestCase
from django.dispatch import Signal

from lms.djangoapps.offline_mode.handlers import listen_course_publish
from lms.djangoapps.offline_mode.tasks import generate_course_media

# Mocking the XBLOCK_PUBLISHED signal
XBLOCK_PUBLISHED = Signal(providing_args=["course_key"])

class ListenCoursePublishSignalTest(TestCase):

def setUp(self):
self.course_key = 'course-v1:edX+DemoX+Demo_Course'

@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)

# Check if the generate_course_media task was called with the correct arguments
mock_generate_course_media.assert_called_once_with(self.course_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)

# Check that the generate_course_media task was not called since the feature is disabled
mock_generate_course_media.assert_not_called()
71 changes: 71 additions & 0 deletions lms/djangoapps/offline_mode/tests/test_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# lint-amnesty, pylint: disable=missing-module-docstring
from unittest import mock
from unittest.mock import patch

import ddt
from django.test import TestCase
from celery import shared_task

from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from opaque_keys.edx.keys import CourseKey

from lms.djangoapps.offline_mode.tasks import generate_course_media
from lms.djangoapps.offline_mode.utils.xblock_helpers import (
generate_offline_content,
xblock_view_handler,
generate_request_with_service_user,
is_offline_supported,
)


@ddt.ddt
class TestGenerateCourseMediaTask(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
@patch('lms.djangoapps.offline_mode.tasks.generate_request_with_service_user')
@patch('lms.djangoapps.offline_mode.tasks.modulestore')
@patch('lms.djangoapps.offline_mode.tasks.xblock_view_handler')
@patch('lms.djangoapps.offline_mode.tasks.generate_offline_content')
@patch('lms.djangoapps.offline_mode.tasks.is_offline_supported')
def test_generate_course_media(self, mock_is_offline_supported, mock_generate_offline_content, mock_xblock_view_handler, mock_modulestore, mock_generate_request_with_service_user):
# Arrange
course_id = 'course-v1:edX+DemoX+Demo_Course'
course_key = CourseKey.from_string(course_id)
request = mock.Mock()
mock_generate_request_with_service_user.return_value = request
mock_xblock = mock.Mock(category='problem')
mock_modulestore().get_items.return_value = [mock_xblock]
mock_is_offline_supported.return_value = False
html_data = '<div>Sample HTML</div>'
mock_xblock_view_handler.return_value = html_data

# Act
generate_course_media(course_id)

# Assert
mock_generate_request_with_service_user.assert_called_once()
mock_modulestore().get_items.assert_called_once_with(course_key, qualifiers={'category': 'problem'})
mock_is_offline_supported.assert_called_once_with(mock_xblock)
mock_xblock_view_handler.assert_called_once_with(request, mock_xblock)
mock_generate_offline_content.assert_called_once_with(mock_xblock, html_data)

@patch('lms.djangoapps.offline_mode.tasks.generate_request_with_service_user')
@patch('lms.djangoapps.offline_mode.tasks.modulestore')
@patch('lms.djangoapps.offline_mode.tasks.is_offline_supported')
def test_generate_course_media_offline_supported(self, mock_is_offline_supported, mock_modulestore, mock_generate_request_with_service_user):
# Arrange
course_id = 'course-v1:edX+DemoX+Demo_Course'
course_key = CourseKey.from_string(course_id)
request = mock.Mock()
mock_generate_request_with_service_user.return_value = request
mock_xblock = mock.Mock(category='problem')
mock_modulestore().get_items.return_value = [mock_xblock]
mock_is_offline_supported.return_value = True

# Act
generate_course_media(course_id)

# Assert
mock_generate_request_with_service_user.assert_called_once()
mock_modulestore().get_items.assert_called_once_with(course_key, qualifiers={'category': 'problem'})
mock_is_offline_supported.assert_called_once_with(mock_xblock)
self.assertFalse(mock_xblock_view_handler.called)
self.assertFalse(mock_generate_offline_content.called)
17 changes: 17 additions & 0 deletions lms/djangoapps/offline_mode/toggles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
Toggles for the Offline Mode Experience.
"""

from edx_toggles.toggles import WaffleFlag

# .. toggle_name: offline_node.media_generation_enabled
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: This flag enables media generation for offline mode.
# .. toggle_warnings: None
# .. toggle_use_cases: opt_out
# .. toggle_creation_date: 2024-05-20
# .. toggle_target_removal_date: None
MEDIA_GENERATION_ENABLED = WaffleFlag(
'offline_node.media_generation_enabled', module_name=__name__, log_prefix='offline_mode'
)
Loading