Merge pull request #3466 from lduarte1991/lduarte-harvardx
Reconnecting Token Generator for Annotation Tool
This commit is contained in:
@@ -318,7 +318,7 @@ PIPELINE_CSS = {
|
||||
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
|
||||
'css/vendor/jquery.qtip.min.css',
|
||||
'js/vendor/markitup/skins/simple/style.css',
|
||||
'js/vendor/markitup/sets/wiki/style.css'
|
||||
'js/vendor/markitup/sets/wiki/style.css',
|
||||
],
|
||||
'output_filename': 'css/cms-style-vendor.css',
|
||||
},
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
'''
|
||||
Firebase - library to generate a token
|
||||
License: https://github.com/firebase/firebase-token-generator-python/blob/master/LICENSE
|
||||
Tweaked and Edited by @danielcebrianr and @lduarte1991
|
||||
|
||||
This library will take either objects or strings and use python's built-in encoding
|
||||
system as specified by RFC 3548. Thanks to the firebase team for their open-source
|
||||
library. This was made specifically for speaking with the annotation_storage_url and
|
||||
can be used and expanded, but not modified by anyone else needing such a process.
|
||||
'''
|
||||
from base64 import urlsafe_b64encode
|
||||
import hashlib
|
||||
import hmac
|
||||
import sys
|
||||
try:
|
||||
import json
|
||||
except ImportError:
|
||||
import simplejson as json
|
||||
|
||||
__all__ = ['create_token']
|
||||
|
||||
TOKEN_SEP = '.'
|
||||
|
||||
|
||||
def create_token(secret, data):
|
||||
'''
|
||||
Simply takes in the secret key and the data and
|
||||
passes it to the local function _encode_token
|
||||
'''
|
||||
return _encode_token(secret, data)
|
||||
|
||||
|
||||
if sys.version_info < (2, 7):
|
||||
def _encode(bytes_data):
|
||||
'''
|
||||
Takes a json object, string, or binary and
|
||||
uses python's urlsafe_b64encode to encode data
|
||||
and make it safe pass along in a url.
|
||||
To make sure it does not conflict with variables
|
||||
we make sure equal signs are removed.
|
||||
More info: docs.python.org/2/library/base64.html
|
||||
'''
|
||||
encoded = urlsafe_b64encode(bytes(bytes_data))
|
||||
return encoded.decode('utf-8').replace('=', '')
|
||||
else:
|
||||
def _encode(bytes_info):
|
||||
'''
|
||||
Same as above function but for Python 2.7 or later
|
||||
'''
|
||||
encoded = urlsafe_b64encode(bytes_info)
|
||||
return encoded.decode('utf-8').replace('=', '')
|
||||
|
||||
|
||||
def _encode_json(obj):
|
||||
'''
|
||||
Before a python dict object can be properly encoded,
|
||||
it must be transformed into a jason object and then
|
||||
transformed into bytes to be encoded using the function
|
||||
defined above.
|
||||
'''
|
||||
return _encode(bytearray(json.dumps(obj), 'utf-8'))
|
||||
|
||||
|
||||
def _sign(secret, to_sign):
|
||||
'''
|
||||
This function creates a sign that goes at the end of the
|
||||
message that is specific to the secret and not the actual
|
||||
content of the encoded body.
|
||||
More info on hashing: http://docs.python.org/2/library/hmac.html
|
||||
The function creates a hashed values of the secret and to_sign
|
||||
and returns the digested values based the secure hash
|
||||
algorithm, 256
|
||||
'''
|
||||
def portable_bytes(string):
|
||||
'''
|
||||
Simply transforms a string into a bytes object,
|
||||
which is a series of immutable integers 0<=x<=256.
|
||||
Always try to encode as utf-8, unless it is not
|
||||
compliant.
|
||||
'''
|
||||
try:
|
||||
return bytes(string, 'utf-8')
|
||||
except TypeError:
|
||||
return bytes(string)
|
||||
return _encode(hmac.new(portable_bytes(secret), portable_bytes(to_sign), hashlib.sha256).digest()) # pylint: disable=E1101
|
||||
|
||||
|
||||
def _encode_token(secret, claims):
|
||||
'''
|
||||
This is the main function that takes the secret token and
|
||||
the data to be transmitted. There is a header created for decoding
|
||||
purposes. Token_SEP means that a period/full stop separates the
|
||||
header, data object/message, and signatures.
|
||||
'''
|
||||
encoded_header = _encode_json({'typ': 'JWT', 'alg': 'HS256'})
|
||||
encoded_claims = _encode_json(claims)
|
||||
secure_bits = '%s%s%s' % (encoded_header, TOKEN_SEP, encoded_claims)
|
||||
sig = _sign(secret, secure_bits)
|
||||
return '%s%s%s' % (secure_bits, TOKEN_SEP, sig)
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
This test will run for firebase_token_generator.py.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from student.firebase_token_generator import _encode, _encode_json, _encode_token, create_token
|
||||
|
||||
|
||||
class TokenGenerator(TestCase):
|
||||
"""
|
||||
Tests for the file firebase_token_generator.py
|
||||
"""
|
||||
def test_encode(self):
|
||||
"""
|
||||
This tests makes sure that no matter what version of python
|
||||
you have, the _encode function still returns the appropriate result
|
||||
for a string.
|
||||
"""
|
||||
expected = "dGVzdDE"
|
||||
result = _encode("test1")
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_encode_json(self):
|
||||
"""
|
||||
Same as above, but this one focuses on a python dict type
|
||||
transformed into a json object and then encoded.
|
||||
"""
|
||||
expected = "eyJ0d28iOiAidGVzdDIiLCAib25lIjogInRlc3QxIn0"
|
||||
result = _encode_json({'one': 'test1', 'two': 'test2'})
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_create_token(self):
|
||||
"""
|
||||
Unlike its counterpart in student/views.py, this function
|
||||
just checks for the encoding of a token. The other function
|
||||
will test depending on time and user.
|
||||
"""
|
||||
expected = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.-p1sr7uwCapidTQ0qB7DdU2dbF-hViKpPNN_5vD10t8"
|
||||
result1 = _encode_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
|
||||
result2 = create_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
|
||||
self.assertEqual(expected, result1)
|
||||
self.assertEqual(expected, result2)
|
||||
@@ -26,7 +26,7 @@ from mock import Mock, patch
|
||||
|
||||
from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user
|
||||
from student.views import (process_survey_link, _cert_info,
|
||||
change_enrollment, complete_course_mode_info, token)
|
||||
change_enrollment, complete_course_mode_info)
|
||||
from student.tests.factories import UserFactory, CourseModeFactory
|
||||
|
||||
import shoppingcart
|
||||
@@ -498,26 +498,3 @@ class AnonymousLookupTable(TestCase):
|
||||
anonymous_id = anonymous_id_for_user(self.user, self.course.id)
|
||||
real_user = user_by_anonymous_id(anonymous_id)
|
||||
self.assertEqual(self.user, real_user)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class Token(ModuleStoreTestCase):
|
||||
"""
|
||||
Test for the token generator. This creates a random course and passes it through the token file which generates the
|
||||
token that will be passed in to the annotation_storage_url.
|
||||
"""
|
||||
request_factory = RequestFactory()
|
||||
COURSE_SLUG = "100"
|
||||
COURSE_NAME = "test_course"
|
||||
COURSE_ORG = "edx"
|
||||
|
||||
def setUp(self):
|
||||
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
|
||||
self.user = User.objects.create(username="username", email="username")
|
||||
self.req = self.request_factory.post('/token?course_id=edx/100/test_course', {'user': self.user})
|
||||
self.req.user = self.user
|
||||
|
||||
def test_token(self):
|
||||
expected = HttpResponse("eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3N1ZWRBdCI6ICIyMDE0LTAxLTIzVDE5OjM1OjE3LjUyMjEwNC01OjAwIiwgImNvbnN1bWVyS2V5IjogInh4eHh4eHh4LXh4eHgteHh4eC14eHh4LXh4eHh4eHh4eHh4eCIsICJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.OjWz9mzqJnYuzX-f3uCBllqJUa8PVWJjcDy_McfxLvc", mimetype="text/plain")
|
||||
response = token(self.req)
|
||||
self.assertEqual(expected.content.split('.')[0], response.content.split('.')[0])
|
||||
|
||||
@@ -43,7 +43,6 @@ from student.models import (
|
||||
create_comments_service_user, PasswordHistory
|
||||
)
|
||||
from student.forms import PasswordResetFormNoActive
|
||||
from student.firebase_token_generator import create_token
|
||||
|
||||
from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow
|
||||
from certificates.models import CertificateStatuses, certificate_status_for_student
|
||||
@@ -1850,26 +1849,3 @@ def change_email_settings(request):
|
||||
track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')
|
||||
|
||||
return JsonResponse({"success": True})
|
||||
|
||||
|
||||
@login_required
|
||||
def token(request):
|
||||
'''
|
||||
Return a token for the backend of annotations.
|
||||
It uses the course id to retrieve a variable that contains the secret
|
||||
token found in inheritance.py. It also contains information of when
|
||||
the token was issued. This will be stored with the user along with
|
||||
the id for identification purposes in the backend.
|
||||
'''
|
||||
course_id = request.GET.get("course_id")
|
||||
course = course_from_id(course_id)
|
||||
dtnow = datetime.datetime.now()
|
||||
dtutcnow = datetime.datetime.utcnow()
|
||||
delta = dtnow - dtutcnow
|
||||
newhour, newmin = divmod((delta.days * 24 * 60 * 60 + delta.seconds + 30) // 60, 60)
|
||||
newtime = "%s%+02d:%02d" % (dtnow.isoformat(), newhour, newmin)
|
||||
secret = course.annotation_token_secret
|
||||
custom_data = {"issuedAt": newtime, "consumerKey": secret, "userId": request.user.email, "ttl": 86400}
|
||||
newtoken = create_token(secret, custom_data)
|
||||
response = HttpResponse(newtoken, mimetype="text/plain")
|
||||
return response
|
||||
|
||||
32
common/lib/xmodule/xmodule/annotator_token.py
Normal file
32
common/lib/xmodule/xmodule/annotator_token.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
This file contains a function used to retrieve the token for the annotation backend
|
||||
without having to create a view, but just returning a string instead.
|
||||
|
||||
It can be called from other files by using the following:
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
"""
|
||||
import datetime
|
||||
from firebase_token_generator import create_token
|
||||
|
||||
|
||||
def retrieve_token(userid, secret):
|
||||
'''
|
||||
Return a token for the backend of annotations.
|
||||
It uses the course id to retrieve a variable that contains the secret
|
||||
token found in inheritance.py. It also contains information of when
|
||||
the token was issued. This will be stored with the user along with
|
||||
the id for identification purposes in the backend.
|
||||
'''
|
||||
|
||||
# the following five lines of code allows you to include the default timezone in the iso format
|
||||
# for more information: http://stackoverflow.com/questions/3401428/how-to-get-an-isoformat-datetime-string-including-the-default-timezone
|
||||
dtnow = datetime.datetime.now()
|
||||
dtutcnow = datetime.datetime.utcnow()
|
||||
delta = dtnow - dtutcnow
|
||||
newhour, newmin = divmod((delta.days * 24 * 60 * 60 + delta.seconds + 30) // 60, 60)
|
||||
newtime = "%s%+02d:%02d" % (dtnow.isoformat(), newhour, newmin)
|
||||
# uses the issued time (UTC plus timezone), the consumer key and the user's email to maintain a
|
||||
# federated system in the annotation backend server
|
||||
custom_data = {"issuedAt": newtime, "consumerKey": secret, "userId": userid, "ttl": 86400}
|
||||
newtoken = create_token(secret, custom_data)
|
||||
return newtoken
|
||||
20
common/lib/xmodule/xmodule/tests/test_annotator_token.py
Normal file
20
common/lib/xmodule/xmodule/tests/test_annotator_token.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
This test will run for annotator_token.py
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
|
||||
class TokenRetriever(unittest.TestCase):
|
||||
"""
|
||||
Tests to make sure that when passed in a username and secret token, that it will be encoded correctly
|
||||
"""
|
||||
def test_token(self):
|
||||
"""
|
||||
Test for the token generator. Give an a random username and secret token, it should create the properly encoded string of text.
|
||||
"""
|
||||
expected = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3N1ZWRBdCI6ICIyMDE0LTAyLTI3VDE3OjAwOjQyLjQwNjQ0MSswOjAwIiwgImNvbnN1bWVyS2V5IjogImZha2Vfc2VjcmV0IiwgInVzZXJJZCI6ICJ1c2VybmFtZSIsICJ0dGwiOiA4NjQwMH0.Dx1PoF-7mqBOOSGDMZ9R_s3oaaLRPnn6CJgGGF2A5CQ"
|
||||
response = retrieve_token("username", "fake_secret")
|
||||
self.assertEqual(expected.split('.')[0], response.split('.')[0])
|
||||
self.assertNotEqual(expected.split('.')[2], response.split('.')[2])
|
||||
@@ -38,17 +38,6 @@ class TextAnnotationModuleTestCase(unittest.TestCase):
|
||||
ScopeIds(None, None, None, None)
|
||||
)
|
||||
|
||||
def test_render_content(self):
|
||||
"""
|
||||
Tests to make sure the sample xml is rendered and that it forms a valid xmltree
|
||||
that does not contain a display_name.
|
||||
"""
|
||||
content = self.mod._render_content() # pylint: disable=W0212
|
||||
self.assertIsNotNone(content)
|
||||
element = etree.fromstring(content)
|
||||
self.assertIsNotNone(element)
|
||||
self.assertFalse('display_name' in element.attrib, "Display Name should have been deleted from Content")
|
||||
|
||||
def test_extract_instructions(self):
|
||||
"""
|
||||
Tests to make sure that the instructions are correctly pulled from the sample xml above.
|
||||
@@ -70,5 +59,5 @@ class TextAnnotationModuleTestCase(unittest.TestCase):
|
||||
Tests the function that passes in all the information in the context that will be used in templates/textannotation.html
|
||||
"""
|
||||
context = self.mod.get_html()
|
||||
for key in ['display_name', 'tag', 'source', 'instructions_html', 'content_html', 'annotation_storage']:
|
||||
for key in ['display_name', 'tag', 'source', 'instructions_html', 'content_html', 'annotation_storage', 'token']:
|
||||
self.assertIn(key, context)
|
||||
|
||||
@@ -34,100 +34,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase):
|
||||
ScopeIds(None, None, None, None)
|
||||
)
|
||||
|
||||
def test_annotation_class_attr_default(self):
|
||||
"""
|
||||
Makes sure that it can detect annotation values in text-form if user
|
||||
decides to add text to the area below video, video functionality is completely
|
||||
found in javascript.
|
||||
"""
|
||||
xml = '<annotation title="x" body="y" problem="0">test</annotation>'
|
||||
element = etree.fromstring(xml)
|
||||
|
||||
expected_attr = {'class': {'value': 'annotatable-span highlight'}}
|
||||
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_class_attr_with_valid_highlight(self):
|
||||
"""
|
||||
Same as above but more specific to an area that is highlightable in the appropriate
|
||||
color designated.
|
||||
"""
|
||||
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
|
||||
|
||||
for color in self.mod.highlight_colors:
|
||||
element = etree.fromstring(xml.format(highlight=color))
|
||||
value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color)
|
||||
|
||||
expected_attr = {'class': {
|
||||
'value': value,
|
||||
'_delete': 'highlight'}
|
||||
}
|
||||
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_class_attr_with_invalid_highlight(self):
|
||||
"""
|
||||
Same as above, but checked with invalid colors.
|
||||
"""
|
||||
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
|
||||
|
||||
for invalid_color in ['rainbow', 'blink', 'invisible', '', None]:
|
||||
element = etree.fromstring(xml.format(highlight=invalid_color))
|
||||
expected_attr = {'class': {
|
||||
'value': 'annotatable-span highlight',
|
||||
'_delete': 'highlight'}
|
||||
}
|
||||
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_data_attr(self):
|
||||
"""
|
||||
Test that each highlight contains the data information from the annotation itself.
|
||||
"""
|
||||
element = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')
|
||||
|
||||
expected_attr = {
|
||||
'data-comment-body': {'value': 'foo', '_delete': 'body'},
|
||||
'data-comment-title': {'value': 'bar', '_delete': 'title'},
|
||||
'data-problem-id': {'value': '0', '_delete': 'problem'}
|
||||
}
|
||||
|
||||
actual_attr = self.mod._get_annotation_data_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_render_annotation(self):
|
||||
"""
|
||||
Tests to make sure that the spans designating annotations acutally visually render as annotations.
|
||||
"""
|
||||
expected_html = '<span class="annotatable-span highlight highlight-yellow" data-comment-title="x" data-comment-body="y" data-problem-id="0">z</span>'
|
||||
expected_el = etree.fromstring(expected_html)
|
||||
|
||||
actual_el = etree.fromstring('<annotation title="x" body="y" problem="0" highlight="yellow">z</annotation>')
|
||||
self.mod._render_annotation(actual_el) # pylint: disable=W0212
|
||||
|
||||
self.assertEqual(expected_el.tag, actual_el.tag)
|
||||
self.assertEqual(expected_el.text, actual_el.text)
|
||||
self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib))
|
||||
|
||||
def test_render_content(self):
|
||||
"""
|
||||
Like above, but using the entire text, it makes sure that display_name is removed and that there is only one
|
||||
div encompassing the annotatable area.
|
||||
"""
|
||||
content = self.mod._render_content() # pylint: disable=W0212
|
||||
element = etree.fromstring(content)
|
||||
self.assertIsNotNone(element)
|
||||
self.assertEqual('div', element.tag, 'root tag is a div')
|
||||
self.assertFalse('display_name' in element.attrib, "Display Name should have been deleted from Content")
|
||||
|
||||
def test_extract_instructions(self):
|
||||
"""
|
||||
This test ensures that if an instruction exists it is pulled and
|
||||
@@ -160,6 +66,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase):
|
||||
"""
|
||||
Tests to make sure variables passed in truly exist within the html once it is all rendered.
|
||||
"""
|
||||
context = self.mod.get_html()
|
||||
for key in ['display_name', 'content_html', 'instructions_html', 'sourceUrl', 'typeSource', 'poster', 'alert', 'annotation_storage']:
|
||||
context = self.mod.get_html() # pylint: disable=W0212
|
||||
for key in ['display_name', 'instructions_html', 'sourceUrl', 'typeSource', 'poster', 'annotation_storage']:
|
||||
self.assertIn(key, context)
|
||||
|
||||
@@ -6,6 +6,7 @@ from pkg_resources import resource_string
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xblock.core import Scope, String
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
import textwrap
|
||||
|
||||
@@ -30,7 +31,7 @@ class AnnotatableFields(object):
|
||||
scope=Scope.settings,
|
||||
default='Text Annotation',
|
||||
)
|
||||
tags = String(
|
||||
instructor_tags = String(
|
||||
display_name="Tags for Assignments",
|
||||
help="Add tags that automatically highlight in a certain color using the comma-separated form, i.e. imagery:red,parallelism:blue",
|
||||
scope=Scope.settings,
|
||||
@@ -43,6 +44,7 @@ class AnnotatableFields(object):
|
||||
default='None',
|
||||
)
|
||||
annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage")
|
||||
annotation_token_secret = String(help="Secret string for annotation storage", scope=Scope.settings, default="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", display_name="Secret Token String for Annotation")
|
||||
|
||||
|
||||
class TextAnnotationModule(AnnotatableFields, XModule):
|
||||
@@ -59,15 +61,9 @@ class TextAnnotationModule(AnnotatableFields, XModule):
|
||||
|
||||
self.instructions = self._extract_instructions(xmltree)
|
||||
self.content = etree.tostring(xmltree, encoding='unicode')
|
||||
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
|
||||
|
||||
def _render_content(self):
|
||||
""" Renders annotatable content with annotation spans and returns HTML. """
|
||||
xmltree = etree.fromstring(self.content)
|
||||
if 'display_name' in xmltree.attrib:
|
||||
del xmltree.attrib['display_name']
|
||||
|
||||
return etree.tostring(xmltree, encoding='unicode')
|
||||
self.user_email = ""
|
||||
if self.runtime.get_real_user is not None:
|
||||
self.user_email = self.runtime.get_real_user(self.runtime.anonymous_student_id).email
|
||||
|
||||
def _extract_instructions(self, xmltree):
|
||||
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
|
||||
@@ -82,13 +78,13 @@ class TextAnnotationModule(AnnotatableFields, XModule):
|
||||
""" Renders parameters to template. """
|
||||
context = {
|
||||
'display_name': self.display_name_with_default,
|
||||
'tag': self.tags,
|
||||
'tag': self.instructor_tags,
|
||||
'source': self.source,
|
||||
'instructions_html': self.instructions,
|
||||
'content_html': self._render_content(),
|
||||
'annotation_storage': self.annotation_storage_url
|
||||
'content_html': self.content,
|
||||
'annotation_storage': self.annotation_storage_url,
|
||||
'token': retrieve_token(self.user_email, self.annotation_token_secret),
|
||||
}
|
||||
|
||||
return self.system.render_template('textannotation.html', context)
|
||||
|
||||
|
||||
@@ -101,6 +97,7 @@ class TextAnnotationDescriptor(AnnotatableFields, RawDescriptor):
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(TextAnnotationDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
TextAnnotationDescriptor.annotation_storage_url
|
||||
TextAnnotationDescriptor.annotation_storage_url,
|
||||
TextAnnotationDescriptor.annotation_token_secret,
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
@@ -7,6 +7,7 @@ from pkg_resources import resource_string
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xblock.core import Scope, String
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
import textwrap
|
||||
|
||||
@@ -31,7 +32,7 @@ class AnnotatableFields(object):
|
||||
sourceurl = String(help="The external source URL for the video.", display_name="Source URL", scope=Scope.settings, default="http://video-js.zencoder.com/oceans-clip.mp4")
|
||||
poster_url = String(help="Poster Image URL", display_name="Poster URL", scope=Scope.settings, default="")
|
||||
annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage")
|
||||
|
||||
annotation_token_secret = String(help="Secret string for annotation storage", scope=Scope.settings, default="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", display_name="Secret Token String for Annotation")
|
||||
|
||||
class VideoAnnotationModule(AnnotatableFields, XModule):
|
||||
'''Video Annotation Module'''
|
||||
@@ -55,73 +56,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule):
|
||||
|
||||
self.instructions = self._extract_instructions(xmltree)
|
||||
self.content = etree.tostring(xmltree, encoding='unicode')
|
||||
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
|
||||
|
||||
def _get_annotation_class_attr(self, element):
|
||||
""" Returns a dict with the CSS class attribute to set on the annotation
|
||||
and an XML key to delete from the element.
|
||||
"""
|
||||
|
||||
attr = {}
|
||||
cls = ['annotatable-span', 'highlight']
|
||||
highlight_key = 'highlight'
|
||||
color = element.get(highlight_key)
|
||||
|
||||
if color is not None:
|
||||
if color in self.highlight_colors:
|
||||
cls.append('highlight-' + color)
|
||||
attr['_delete'] = highlight_key
|
||||
attr['value'] = ' '.join(cls)
|
||||
|
||||
return {'class': attr}
|
||||
|
||||
def _get_annotation_data_attr(self, element):
|
||||
""" Returns a dict in which the keys are the HTML data attributes
|
||||
to set on the annotation element. Each data attribute has a
|
||||
corresponding 'value' and (optional) '_delete' key to specify
|
||||
an XML attribute to delete.
|
||||
"""
|
||||
|
||||
data_attrs = {}
|
||||
attrs_map = {
|
||||
'body': 'data-comment-body',
|
||||
'title': 'data-comment-title',
|
||||
'problem': 'data-problem-id'
|
||||
}
|
||||
|
||||
for xml_key in attrs_map.keys():
|
||||
if xml_key in element.attrib:
|
||||
value = element.get(xml_key, '')
|
||||
html_key = attrs_map[xml_key]
|
||||
data_attrs[html_key] = {'value': value, '_delete': xml_key}
|
||||
|
||||
return data_attrs
|
||||
|
||||
def _render_annotation(self, element):
|
||||
""" Renders an annotation element for HTML output. """
|
||||
attr = {}
|
||||
attr.update(self._get_annotation_class_attr(element))
|
||||
attr.update(self._get_annotation_data_attr(element))
|
||||
|
||||
element.tag = 'span'
|
||||
|
||||
for key in attr.keys():
|
||||
element.set(key, attr[key]['value'])
|
||||
if '_delete' in attr[key] and attr[key]['_delete'] is not None:
|
||||
delete_key = attr[key]['_delete']
|
||||
del element.attrib[delete_key]
|
||||
|
||||
def _render_content(self):
|
||||
""" Renders annotatable content with annotation spans and returns HTML. """
|
||||
xmltree = etree.fromstring(self.content)
|
||||
xmltree.tag = 'div'
|
||||
if 'display_name' in xmltree.attrib:
|
||||
del xmltree.attrib['display_name']
|
||||
|
||||
for element in xmltree.findall('.//annotation'):
|
||||
self._render_annotation(element)
|
||||
|
||||
return etree.tostring(xmltree, encoding='unicode')
|
||||
self.user_email = ""
|
||||
if self.runtime.get_real_user is not None:
|
||||
self.user_email = self.runtime.get_real_user(self.runtime.anonymous_student_id).email
|
||||
|
||||
def _extract_instructions(self, xmltree):
|
||||
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
|
||||
@@ -154,9 +91,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule):
|
||||
'sourceUrl': self.sourceurl,
|
||||
'typeSource': extension,
|
||||
'poster': self.poster_url,
|
||||
'alert': self,
|
||||
'content_html': self._render_content(),
|
||||
'annotation_storage': self.annotation_storage_url
|
||||
'content_html': self.content,
|
||||
'annotation_storage': self.annotation_storage_url,
|
||||
'token': retrieve_token(self.user_email, self.annotation_token_secret),
|
||||
}
|
||||
|
||||
return self.system.render_template('videoannotation.html', context)
|
||||
@@ -171,6 +108,7 @@ class VideoAnnotationDescriptor(AnnotatableFields, RawDescriptor):
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(VideoAnnotationDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
VideoAnnotationDescriptor.annotation_storage_url
|
||||
VideoAnnotationDescriptor.annotation_storage_url,
|
||||
VideoAnnotationDescriptor.annotation_token_secret,
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
22
common/static/js/vendor/ova/annotator-full-firebase-auth.js
vendored
Normal file
22
common/static/js/vendor/ova/annotator-full-firebase-auth.js
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
Annotator.Plugin.Auth.prototype.haveValidToken = function() {
|
||||
return (
|
||||
this._unsafeToken &&
|
||||
this._unsafeToken.d.issuedAt &&
|
||||
this._unsafeToken.d.ttl &&
|
||||
this._unsafeToken.d.consumerKey &&
|
||||
this.timeToExpiry() > 0
|
||||
);
|
||||
};
|
||||
|
||||
Annotator.Plugin.Auth.prototype.timeToExpiry = function() {
|
||||
var expiry, issue, now, timeToExpiry;
|
||||
now = new Date().getTime() / 1000;
|
||||
issue = createDateFromISO8601(this._unsafeToken.d.issuedAt).getTime() / 1000;
|
||||
expiry = issue + this._unsafeToken.d.ttl;
|
||||
timeToExpiry = expiry - now;
|
||||
if (timeToExpiry > 0) {
|
||||
return timeToExpiry;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ from edxmako.shortcuts import render_to_response
|
||||
from courseware.courses import get_course_with_access
|
||||
from notes.models import Note
|
||||
from notes.utils import notes_enabled_for_course
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -22,7 +23,8 @@ def notes(request, course_id):
|
||||
'course': course,
|
||||
'notes': notes,
|
||||
'student': student,
|
||||
'storage': storage
|
||||
'storage': storage,
|
||||
'token': retrieve_token(student.email, course.annotation_token_secret),
|
||||
}
|
||||
|
||||
return render_to_response('notes.html', context)
|
||||
|
||||
@@ -826,6 +826,7 @@ main_vendor_js = [
|
||||
'js/vendor/swfobject/swfobject.js',
|
||||
'js/vendor/jquery.ba-bbq.min.js',
|
||||
'js/vendor/ova/annotator-full.js',
|
||||
'js/vendor/ova/annotator-full-firebase-auth.js',
|
||||
'js/vendor/ova/video.dev.js',
|
||||
'js/vendor/ova/vjs.youtube.js',
|
||||
'js/vendor/ova/rangeslider.js',
|
||||
|
||||
@@ -68,10 +68,8 @@
|
||||
|
||||
//Grab uri of the course
|
||||
var parts = window.location.href.split("/"),
|
||||
uri = '',
|
||||
courseid;
|
||||
uri = '';
|
||||
for (var index = 0; index <= 6; index += 1) uri += parts[index]+"/"; //Get the unit url
|
||||
courseid = parts[4] + "/" + parts[5] + "/" + parts[6];
|
||||
var pagination = 100,
|
||||
is_staff = false,
|
||||
options = {
|
||||
@@ -130,7 +128,7 @@
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
tokenUrl: location.protocol+'//'+location.host+"/token?course_id="+courseid
|
||||
token: "${token}"
|
||||
},
|
||||
store: {
|
||||
// The endpoint of the store on your server.
|
||||
|
||||
@@ -1,64 +1,63 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<div class="annotatable-wrapper">
|
||||
<div class="annotatable-header">
|
||||
% if display_name is not UNDEFINED and display_name is not None:
|
||||
<div class="annotatable-title">${display_name}</div>
|
||||
% endif
|
||||
</div>
|
||||
% if instructions_html is not UNDEFINED and instructions_html is not None:
|
||||
<div class="annotatable-section shaded">
|
||||
<div class="annotatable-section-title">
|
||||
${_('Instructions')}
|
||||
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">${_('Collapse Instructions')}</a>
|
||||
</div>
|
||||
<div class="annotatable-section-body annotatable-instructions">
|
||||
${instructions_html}
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
<div class="annotatable-section">
|
||||
<div class="annotatable-content">
|
||||
<div id="textHolder">${content_html}</div>
|
||||
<div id="sourceCitation">${_('Source:')} ${source}</div>
|
||||
<div id="catchDIV">
|
||||
<div class="annotationListContainer">${_('You do not have any notes.')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotatable-header">
|
||||
% if display_name is not UNDEFINED and display_name is not None:
|
||||
<div class="annotatable-title">${display_name}</div>
|
||||
% endif
|
||||
</div>
|
||||
% if instructions_html is not UNDEFINED and instructions_html is not None:
|
||||
<div class="annotatable-section shaded">
|
||||
<div class="annotatable-section-title">
|
||||
${_('Instructions')}
|
||||
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">${_('Collapse Instructions')}</a>
|
||||
</div>
|
||||
<div class="annotatable-section-body annotatable-instructions">
|
||||
${instructions_html}
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
<div class="annotatable-section">
|
||||
<div class="annotatable-content">
|
||||
<div id="textHolder">${content_html}</div>
|
||||
<div id="sourceCitation">${_('Source:')} ${source}</div>
|
||||
<div id="catchDIV">
|
||||
<div class="annotationListContainer">${_('You do not have any notes.')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function onClickHideInstructions(){
|
||||
//Reset function if there is more than one event handler
|
||||
$(this).off();
|
||||
$(this).on('click',onClickHideInstructions);
|
||||
var hide = $(this).html()=='Collapse Instructions'?true:false,
|
||||
cls, txt,slideMethod;
|
||||
txt = (hide ? 'Expand' : 'Collapse') + ' Instructions';
|
||||
cls = (hide ? ['expanded', 'collapsed'] : ['collapsed', 'expanded']);
|
||||
slideMethod = (hide ? 'slideUp' : 'slideDown');
|
||||
$(this).text(txt).removeClass(cls[0]).addClass(cls[1]);
|
||||
$(this).parents('.annotatable-section:first').find('.annotatable-instructions')[slideMethod]();
|
||||
}
|
||||
$('.annotatable-toggle-instructions').on('click', onClickHideInstructions);
|
||||
|
||||
//Grab uri of the course
|
||||
function onClickHideInstructions(){
|
||||
//Reset function if there is more than one event handler
|
||||
$(this).off();
|
||||
$(this).on('click',onClickHideInstructions);
|
||||
var hide = $(this).html()=='Collapse Instructions'?true:false,
|
||||
cls, txt,slideMethod;
|
||||
txt = (hide ? 'Expand' : 'Collapse') + ' Instructions';
|
||||
cls = (hide ? ['expanded', 'collapsed'] : ['collapsed', 'expanded']);
|
||||
slideMethod = (hide ? 'slideUp' : 'slideDown');
|
||||
$(this).text(txt).removeClass(cls[0]).addClass(cls[1]);
|
||||
$(this).parents('.annotatable-section:first').find('.annotatable-instructions')[slideMethod]();
|
||||
}
|
||||
$('.annotatable-toggle-instructions').on('click', onClickHideInstructions);
|
||||
|
||||
//Grab uri of the course
|
||||
var parts = window.location.href.split("/"),
|
||||
uri = '',
|
||||
courseid;
|
||||
for (var index = 0; index <= 9; index += 1) uri += parts[index]+"/"; //Get the unit url
|
||||
courseid = parts[4] + "/" + parts[5] + "/" + parts[6];
|
||||
//Change uri in cms
|
||||
var lms_location = $('.sidebar .preview-button').attr('href');
|
||||
if (typeof lms_location!='undefined'){
|
||||
courseid = parts[4].split(".").join("/");
|
||||
uri = window.location.protocol;
|
||||
for (var index = 0; index <= 9; index += 1) uri += lms_location.split("/")[index]+"/"; //Get the unit url
|
||||
}
|
||||
var pagination = 100,
|
||||
is_staff = !('${user.is_staff}'=='False'),
|
||||
uri = '';
|
||||
for (var index = 0; index <= 9; index += 1) uri += parts[index]+"/"; //Get the unit url
|
||||
//Change uri in cms
|
||||
var lms_location = $('.sidebar .preview-button').attr('href');
|
||||
if (typeof lms_location!='undefined'){
|
||||
uri = window.location.protocol;
|
||||
for (var index = 0; index <= 9; index += 1) uri += lms_location.split("/")[index]+"/"; //Get the unit url
|
||||
}
|
||||
var unit_id = $('#sequence-list').find('.active').attr("data-element");
|
||||
uri += unit_id;
|
||||
var pagination = 100,
|
||||
is_staff = !('${user.is_staff}'=='False'),
|
||||
options = {
|
||||
optionsAnnotator: {
|
||||
permissions:{
|
||||
@@ -89,7 +88,7 @@
|
||||
if (annotation.permissions) {
|
||||
tokens = annotation.permissions[action] || [];
|
||||
if (is_staff){
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
if (tokens.length === 0) {
|
||||
return true;
|
||||
@@ -115,7 +114,7 @@
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
tokenUrl: location.protocol+'//'+location.host+"/token?course_id="+courseid
|
||||
token: "${token}"
|
||||
},
|
||||
store: {
|
||||
// The endpoint of the store on your server.
|
||||
@@ -140,11 +139,14 @@
|
||||
offset:0,
|
||||
uri:uri,
|
||||
media:'text',
|
||||
userid:'${user.email}',
|
||||
userid:'${user.email}',
|
||||
}
|
||||
},
|
||||
highlightTags:{
|
||||
tag: "${tag}",
|
||||
},
|
||||
diacriticMarks:{
|
||||
diacritics: "${diacritic_marks}"
|
||||
}
|
||||
},
|
||||
optionsVideoJS: {techOrder: ["html5","flash","youtube"]},
|
||||
@@ -161,12 +163,11 @@
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
var imgURLRoot = "${settings.STATIC_URL}" + "js/vendor/ova/catch/img/";
|
||||
tinyMCE.baseURL = "${settings.STATIC_URL}" + "js/vendor/ova";
|
||||
|
||||
//remove old instances
|
||||
var imgURLRoot = "${settings.STATIC_URL}" + "js/vendor/ova/catch/img/";
|
||||
tinyMCE.baseURL = "${settings.STATIC_URL}" + "js/vendor/ova";
|
||||
|
||||
//remove old instances
|
||||
if (Annotator._instances.length !== 0) {
|
||||
$('#textHolder').annotator("destroy");
|
||||
}
|
||||
@@ -174,7 +175,6 @@
|
||||
//Load the plugin Video/Text Annotation
|
||||
var ova = new OpenVideoAnnotation.Annotator($('#textHolder'),options);
|
||||
|
||||
|
||||
//Catch
|
||||
var annotator = ova.annotator,
|
||||
catchOptions = {
|
||||
@@ -183,7 +183,7 @@
|
||||
imageUrlRoot:imgURLRoot,
|
||||
showMediaSelector: false,
|
||||
showPublicPrivate: true,
|
||||
userId:'${user.email}',
|
||||
userId:'${user.email}',
|
||||
pagination:pagination,//Number of Annotations per load in the pagination,
|
||||
flags:is_staff
|
||||
},
|
||||
|
||||
@@ -49,18 +49,16 @@
|
||||
|
||||
//Grab uri of the course
|
||||
var parts = window.location.href.split("/"),
|
||||
uri = '',
|
||||
courseid;
|
||||
uri = '';
|
||||
for (var index = 0; index <= 9; index += 1) uri += parts[index]+"/"; //Get the unit url
|
||||
courseid = parts[4] + "/" + parts[5] + "/" + parts[6];
|
||||
//Change uri in cms
|
||||
var lms_location = $('.sidebar .preview-button').attr('href');
|
||||
if (typeof lms_location!='undefined'){
|
||||
courseid = parts[4].split(".").join("/");
|
||||
uri = window.location.protocol;
|
||||
for (var index = 0; index <= 9; index += 1) uri += lms_location.split("/")[index]+"/"; //Get the unit url
|
||||
}
|
||||
|
||||
var unit_id = $('#sequence-list').find('.active').attr("data-element");
|
||||
uri += unit_id;
|
||||
var pagination = 100,
|
||||
is_staff = !('${user.is_staff}'=='False'),
|
||||
options = {
|
||||
@@ -119,7 +117,7 @@
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
tokenUrl: location.protocol+'//'+location.host+"/token?course_id="+courseid
|
||||
token: "${token}"
|
||||
},
|
||||
store: {
|
||||
// The endpoint of the store on your server.
|
||||
@@ -175,8 +173,6 @@
|
||||
var ova = new OpenVideoAnnotation.Annotator($('#videoHolder'),options);
|
||||
|
||||
ova.annotator.addPlugin('Tags');
|
||||
|
||||
|
||||
|
||||
//Catch
|
||||
var annotator = ova.annotator,
|
||||
@@ -186,7 +182,7 @@
|
||||
imageUrlRoot:imgURLRoot,
|
||||
showMediaSelector: false,
|
||||
showPublicPrivate: true,
|
||||
userId:'${user.email}',
|
||||
userId:'${user.email}',
|
||||
pagination:pagination,//Number of Annotations per load in the pagination,
|
||||
flags:is_staff
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@ urlpatterns = ('', # nopep8
|
||||
url(r'^request_certificate$', 'certificates.views.request_certificate'),
|
||||
url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware
|
||||
url(r'^dashboard$', 'student.views.dashboard', name="dashboard"),
|
||||
url(r'^token$', 'student.views.token', name="token"),
|
||||
url(r'^login$', 'student.views.signin_user', name="signin_user"),
|
||||
url(r'^register$', 'student.views.register_user', name="register_user"),
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ django-method-override==0.1.0
|
||||
djangorestframework==2.3.5
|
||||
django==1.4.12
|
||||
feedparser==5.1.3
|
||||
firebase-token-generator==1.3.2
|
||||
fs==0.4.0
|
||||
GitPython==0.3.2.RC1
|
||||
glob2==0.3
|
||||
|
||||
Reference in New Issue
Block a user