import json import hashlib import re import os import logging import pkg_resources import shutil import xml.etree.ElementTree as ET from functools import partial from django.conf import settings from django.core.files import File from django.core.files.storage import default_storage from django.template import Context, Template from django.utils import timezone from webob import Response from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from xblock.core import XBlock from xblock.fields import Scope, String, Float, Boolean, Dict, DateTime, Integer from xblock.fragment import Fragment # Make '_' a no-op so we can scrape strings _ = lambda text: text log = logging.getLogger(__name__) SCORM_ROOT = os.path.join(settings.MEDIA_ROOT, 'scorm') SCORM_URL = os.path.join(settings.MEDIA_URL, 'scorm') class ScormXBlock(XBlock): display_name = String( display_name=_("Display Name"), help=_("Display name for this module"), default="Scorm", scope=Scope.settings, ) scorm_file = String( display_name=_("Upload scorm file"), scope=Scope.settings, ) path_index_page = String( display_name=_("Path to the index page in scorm file"), scope=Scope.settings, ) scorm_file_meta = Dict( scope=Scope.content ) version_scorm = String( default="SCORM_12", scope=Scope.settings, ) # save completion_status for SCORM_2004 lesson_status = String( scope=Scope.user_state, default='not attempted' ) success_status = String( scope=Scope.user_state, default='unknown' ) data_scorm = Dict( scope=Scope.user_state, default={} ) lesson_score = Float( scope=Scope.user_state, default=0 ) weight = Float( default=1, scope=Scope.settings ) has_score = Boolean( display_name=_("Scored"), help=_("Select False if this component will not receive a numerical score from the Scorm"), default=True, scope=Scope.settings ) icon_class = String( default="video", scope=Scope.settings, ) width = Integer( display_name=_("Display Width (px)"), help=_('Width of iframe, if empty, the default 100%'), scope=Scope.settings ) height = Integer( display_name=_("Display Height (px)"), help=_('Height of iframe'), default=450, scope=Scope.settings ) has_author_view = True def resource_string(self, path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def student_view(self, context=None): context_html = self.get_context_student() template = self.render_template('static/html/scormxblock.html', context_html) frag = Fragment(template) frag.add_css(self.resource_string("static/css/scormxblock.css")) frag.add_javascript(self.resource_string("static/js/src/scormxblock.js")) settings = { 'version_scorm': self.version_scorm } frag.initialize_js('ScormXBlock', json_args=settings) return frag def studio_view(self, context=None): context_html = self.get_context_studio() template = self.render_template('static/html/studio.html', context_html) frag = Fragment(template) frag.add_css(self.resource_string("static/css/scormxblock.css")) frag.add_javascript(self.resource_string("static/js/src/studio.js")) frag.initialize_js('ScormStudioXBlock') return frag def author_view(self, context=None): html = self.render_template("static/html/author_view.html", context) frag = Fragment(html) return frag @XBlock.handler def studio_submit(self, request, suffix=''): self.display_name = request.params['display_name'] self.width = request.params['width'] self.height = request.params['height'] self.has_score = request.params['has_score'] self.icon_class = 'problem' if self.has_score == 'True' else 'video' if hasattr(request.params['file'], 'file'): scorm_file = request.params['file'].file # First, save scorm file in the storage for mobile clients self.scorm_file_meta['sha1'] = self.get_sha1(scorm_file) self.scorm_file_meta['name'] = scorm_file.name self.scorm_file_meta['path'] = path = self._file_storage_path() self.scorm_file_meta['last_updated'] = timezone.now().strftime(DateTime.DATETIME_FORMAT) if default_storage.exists(path): log.info('Removing previously uploaded "{}"'.format(path)) default_storage.delete(path) default_storage.save(path, File(scorm_file)) self.scorm_file_meta['size'] = default_storage.size(path) log.info('"{}" file stored at "{}"'.format(scorm_file, path)) # Check whether SCORM_ROOT exists if not os.path.exists(SCORM_ROOT): os.mkdir(SCORM_ROOT) # Now unpack it into SCORM_ROOT to serve to students later path_to_file = os.path.join(SCORM_ROOT, self.location.block_id) if os.path.exists(path_to_file): shutil.rmtree(path_to_file) if hasattr(scorm_file, 'temporary_file_path'): os.system('unzip {} -d {}'.format(scorm_file.temporary_file_path(), path_to_file)) else: temporary_path = os.path.join(SCORM_ROOT, scorm_file.name) temporary_zip = open(temporary_path, 'wb') scorm_file.open() temporary_zip.write(scorm_file.read()) temporary_zip.close() os.system('unzip {} -d {}'.format(temporary_path, path_to_file)) os.remove(temporary_path) self.set_fields_xblock(path_to_file) return Response(json.dumps({'result': 'success'}), content_type='application/json') @XBlock.json_handler def scorm_get_value(self, data, suffix=''): name = data.get('name') if name in ['cmi.core.lesson_status', 'cmi.completion_status']: return {'value': self.lesson_status} elif name == 'cmi.success_status': return {'value': self.success_status} elif name in ['cmi.core.score.raw', 'cmi.score.raw']: return {'value': self.lesson_score * 100} else: return {'value': self.data_scorm.get(name, '')} @XBlock.json_handler def scorm_set_value(self, data, suffix=''): 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() context.update({"lesson_score": self.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() context.update({"lesson_score": self.lesson_score}) elif name in ['cmi.core.score.raw', 'cmi.score.raw'] and self.has_score: self.lesson_score = int(data.get('value', 0))/100.0 self.publish_grade() context.update({"lesson_score": self.lesson_score}) else: self.data_scorm[name] = data.get('value', '') context.update({"completion_status": self.get_completion_status()}) return context def publish_grade(self): if self.lesson_status == 'failed' or (self.version_scorm == 'SCORM_2004' and self.success_status in ['failed', 'unknown']): self.runtime.publish( self, 'grade', { 'value': 0, 'max_value': self.weight, }) else: self.runtime.publish( self, 'grade', { 'value': self.lesson_score, 'max_value': self.weight, }) def max_score(self): """ Return the maximum score possible. """ return self.weight if self.has_score else None def get_context_studio(self): return { 'field_display_name': self.fields['display_name'], 'field_scorm_file': self.fields['scorm_file'], 'field_has_score': self.fields['has_score'], 'field_width': self.fields['width'], 'field_height': self.fields['height'], 'scorm_xblock': self } def get_context_student(self): scorm_file_path = '' if self.scorm_file: scheme = 'https' if settings.HTTPS == 'on' else 'http' scorm_file_path = '{}://{}{}'.format( scheme, configuration_helpers.get_value('site_domain', settings.ENV_TOKENS.get('LMS_BASE')), self.scorm_file ) return { 'scorm_file_path': scorm_file_path, 'completion_status': self.get_completion_status(), 'scorm_xblock': self } def render_template(self, template_path, context): template_str = self.resource_string(template_path) template = Template(template_str) return template.render(Context(context)) def set_fields_xblock(self, path_to_file): self.path_index_page = 'index.html' try: tree = ET.parse('{}/imsmanifest.xml'.format(path_to_file)) except IOError: pass else: namespace = '' for node in [node for _, node in ET.iterparse('{}/imsmanifest.xml'.format(path_to_file), events=['start-ns'])]: if node[0] == '': namespace = node[1] break root = tree.getroot() if namespace: resource = root.find('{{{0}}}resources/{{{0}}}resource'.format(namespace)) schemaversion = root.find('{{{0}}}metadata/{{{0}}}schemaversion'.format(namespace)) else: resource = root.find('resources/resource') schemaversion = root.find('metadata/schemaversion') if resource: self.path_index_page = resource.get('href') if (schemaversion is not None) and (re.match('^1.2$', schemaversion.text) is None): self.version_scorm = 'SCORM_2004' else: self.version_scorm = 'SCORM_12' self.scorm_file = os.path.join(SCORM_URL, '{}/{}'.format(self.location.block_id, self.path_index_page)) def get_completion_status(self): completion_status = self.lesson_status if self.version_scorm == 'SCORM_2004' and self.success_status != 'unknown': completion_status = self.success_status return completion_status def _file_storage_path(self): """ Get file path of storage. """ path = ( '{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}' '/{sha1}{ext}'.format( loc=self.location, sha1=self.scorm_file_meta['sha1'], ext=os.path.splitext(self.scorm_file_meta['name'])[1] ) ) return path def get_sha1(self, file_descriptor): """ Get file hex digest (fingerprint). """ block_size = 8 * 1024 sha1 = hashlib.sha1() for block in iter(partial(file_descriptor.read, block_size), ''): sha1.update(block) file_descriptor.seek(0) return sha1.hexdigest() def student_view_data(self): """ Inform REST api clients about original file location and it's "freshness". Make sure to include `student_view_data=scormxblock` to URL params in the request. """ if self.scorm_file and self.scorm_file_meta: return { 'last_modified': self.scorm_file_meta.get('last_updated', ''), 'scorm_data': default_storage.url(self._file_storage_path()), 'size': self.scorm_file_meta.get('size', 0), 'index_page': self.path_index_page, } return {} @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("ScormXBlock", """<vertical_demo> <scormxblock/> </vertical_demo> """), ]