Skip to content
This repository has been archived by the owner on Nov 27, 2023. It is now read-only.

Add app scorm_data for mobile sync #48

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Empty file added scorm_app/__init__.py
Empty file.
21 changes: 21 additions & 0 deletions scorm_app/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?


from django.apps import AppConfig

from openedx.core.djangoapps.plugins.constants import ProjectType, PluginURLs


class MobileScormSyncConfig(AppConfig):
name = 'scorm_app'

plugin_app = {
PluginURLs.CONFIG: {
ProjectType.LMS: {
PluginURLs.NAMESPACE: 'scorm_app',
PluginURLs.APP_NAME: 'scorm_app',
PluginURLs.REGEX: '^mobile_xblock_sync/',
PluginURLs.RELATIVE_PATH: 'urls',
}
}
}
10 changes: 10 additions & 0 deletions scorm_app/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.conf.urls import url

from .views import SyncXBlockData

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would use a full path in the import block since it is more explicit instead of using a relative import approach.

Suggested change
from .views import SyncXBlockData
from scorm_app.views import SyncXBlockData



urlpatterns = [
url(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An important question is are we going to use that changes with the project before juniper?

I expect that we are not, so we can use path instead of url as it is recommended for the current Django version.

r'^set_values$', SyncXBlockData.as_view(), name='set_values'
),
]
58 changes: 58 additions & 0 deletions scorm_app/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?


import json

from django.test import RequestFactory
from django.urls import reverse
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import status, views
from rest_framework.response import Response
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError

from lms.djangoapps.courseware.module_render import _invoke_xblock_handler
from lms.djangoapps.mobile_api.decorators import mobile_course_access, mobile_view


@mobile_view()
class SyncXBlockData(views.APIView):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add docstrings with the class description.


def post(self, request, format=None):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same is here, we need to have the proper description of the post method with the example of the expected request data.

# TODO: need to take a handler from the request

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we do not take the handler from the request?

user = request.user
handler = 'scorm_set_values'
response_context = {}

for course_data in request.data.get('courses_data'):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If request.data doesn't have the 'courses_data' Type Error raises. I would like us to handle this option.

course_id = course_data.get('course_id')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we going to work with the None keys in response_context in case 'course_id' is not found in the course_data?

response_context[course_id] = {}
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError:
return Response({'error': '{} is not a valid course key'.format(course_id)},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Response({'error': '{} is not a valid course key'.format(course_id)},
return Response(
{'error': f'{course_id} is not a valid course key'}, status=status.HTTP_404_NOT_FOUND
)

status=status.HTTP_404_NOT_FOUND)

with modulestore().bulk_operations(course_key):
try:
course = modulestore().get_course(course_key)
except ItemNotFoundError:
return Response({'error': '{} does not exist in the modulestore'.format(course_id)},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Response({'error': '{} does not exist in the modulestore'.format(course_id)},
return Response(
{'error': f'{course_id} does not exist in the modulestore'}, status=status.HTTP_404_NOT_FOUND
)

status=status.HTTP_404_NOT_FOUND)

for scorm in course_data.get('xblocks_data'):
factory = RequestFactory()
data = json.dumps(scorm)

scorm_request = factory.post(reverse('scorm_app:set_values'), data,
content_type='application/json')
scorm_request.user = user
scorm_request.session = request.session
scorm_request.user.known = True

usage_id = scorm.get('usage_id')
scorm_response = _invoke_xblock_handler(scorm_request, course_id, usage_id, handler, None,
course=course)
response_context[course_id][usage_id] = json.loads(scorm_response.content)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain in more details approach that is proposed in L45 - 57, truly saying it is not obvious for me.

return Response(response_context)
41 changes: 34 additions & 7 deletions scormxblock/scormxblock.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import time

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please reorder imports (alphabetical order is required)

import json
import hashlib
import re
Expand Down Expand Up @@ -105,6 +106,7 @@ def resource_string(self, path):
data = pkg_resources.resource_string(__name__, path)
return data.decode("utf8")

@XBlock.supports("multi_device")
def student_view(self, context=None):
context_html = self.get_context_student()
template = loader.render_django_template(
Expand Down Expand Up @@ -207,33 +209,53 @@ def scorm_get_value(self, data, suffix=''):

@XBlock.json_handler
def scorm_set_value(self, data, suffix=''):
return self.set_value(data, suffix)

def set_value(self, data, suffix='', set_last_updated_time=True):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new function should be described as well.

context = {'result': 'success'}
name = data.get('name')

if name in ['cmi.core.lesson_status', 'cmi.completion_status']:
self.lesson_status = data.get('value')
if self.has_score and data.get('value') in ['completed', 'failed', 'passed']:
self.publish_grade()
self.publish_grade(set_last_updated_time)
context.update({"lesson_score": self.format_lesson_score})

elif name == 'cmi.success_status':
self.success_status = data.get('value')
if self.has_score:
if self.success_status == 'unknown':
self.lesson_score = 0
self.publish_grade()
self.publish_grade(set_last_updated_time)
context.update({"lesson_score": self.format_lesson_score})
elif name in ['cmi.core.score.raw', 'cmi.score.raw'] and self.has_score:
self.lesson_score = float(data.get('value', 0))/100.0
self.publish_grade()
self.publish_grade(set_last_updated_time)
context.update({"lesson_score": self.format_lesson_score})
else:
self.data_scorm[name] = data.get('value', '')

self.data_scorm[name] = data.get('value', '')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for that change is also unclear, please clarify.

context.update({"completion_status": self.get_completion_status()})
return context

def publish_grade(self):
@XBlock.json_handler
def scorm_get_values(self, data, suffix=''):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New function? What is it stand for?

return self.data_scorm

@XBlock.json_handler
def scorm_set_values(self, data, suffix=''):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

is_updated = False
if self.data_scorm.get('last_updated_time', 0) < data.get('last_updated_time', 0):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please describe the logic of the updating process.

BTW are we sure that request.data contains last_updated_time as an numerical value? Otherwise, it should be verified

Additional question how we can check that last_updated_time from the external request provided in UTC?

for datum in data.get('data'):
self.set_value(datum, suffix, set_last_updated_time=False)
self.data_scorm['last_updated_time'] = int(data.get('last_updated_time', 0))
is_updated = True
context = self.data_scorm
context.update({"is_updated": is_updated})
return context

def publish_grade(self, set_last_updated_time=True):
if set_last_updated_time:
self.data_scorm['last_updated_time'] = int(time.time())
if self.lesson_status == 'failed' or (self.version_scorm == 'SCORM_2004'
and self.success_status in ['failed', 'unknown']):
self.runtime.publish(
Expand Down Expand Up @@ -360,9 +382,14 @@ def student_view_data(self):
Make sure to include `student_view_data=scormxblock` to URL params in the request.
"""
if self.scorm_file and self.scorm_file_meta:

scorm_data = default_storage.url(self._file_storage_path())
if not scorm_data.startswith('http'):
scorm_data = '{}{}'.format(settings.LMS_ROOT_URL, scorm_data)

return {
'last_modified': self.scorm_file_meta.get('last_updated', ''),
'scorm_data': default_storage.url(self._file_storage_path()),
'scorm_data': scorm_data,
'size': self.scorm_file_meta.get('size', 0),
'index_page': self.path_index_page,
}
Expand Down
9 changes: 8 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,21 @@ def package_data(pkg, roots):
description='scormxblock XBlock', # TODO: write a better description.
packages=[
'scormxblock',
'scorm_app'
],
include_package_data=True,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need all files to be included in the package?

zip_safe=False,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need that flag?

install_requires=[
'XBlock',
],
entry_points={
'xblock.v1': [
'scormxblock = scormxblock:ScormXBlock',
]
],
'lms.djangoapp': [
'scorm_app = scorm_app.apps:MobileScormSyncConfig',
],
'cms.djangoapp': [],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A redundant entry point could be omitted.

},
package_data=package_data("scormxblock", ["static", "public", "translations"]),
license="Apache",
Expand Down