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

feat: Offline mode #2590

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions cms/djangoapps/contentstore/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,19 +22,22 @@
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

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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 _
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions cms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', '')
Expand Down
24 changes: 22 additions & 2 deletions lms/djangoapps/mobile_api/course_info/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
87 changes: 85 additions & 2 deletions lms/djangoapps/mobile_api/tests/test_course_info_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
"""
Expand Down
1 change: 1 addition & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down
101 changes: 101 additions & 0 deletions lms/static/js/courseware/bridge.js
Original file line number Diff line number Diff line change
@@ -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);
}

Loading
Loading