TNL-213: Let Students Add Personal Notes to Course Content.
Co-Authored-By: Jean-Michel Claus <jmc@edx.org> Co-Authored-By: Brian Talbot <btalbot@edx.org> Co-Authored-By: Tim Babych <tim@edx.org> Co-Authored-By: Oleg Marshev <oleg@edx.org> Co-Authored-By: Chris Rodriguez <crodriguez@edx.org>
This commit is contained in:
0
lms/djangoapps/edxnotes/__init__.py
Normal file
0
lms/djangoapps/edxnotes/__init__.py
Normal file
54
lms/djangoapps/edxnotes/decorators.py
Normal file
54
lms/djangoapps/edxnotes/decorators.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Decorators related to edXNotes.
|
||||
"""
|
||||
from django.conf import settings
|
||||
import json
|
||||
from edxnotes.helpers import (
|
||||
get_endpoint,
|
||||
get_id_token,
|
||||
get_token_url,
|
||||
generate_uid,
|
||||
is_feature_enabled,
|
||||
)
|
||||
from edxmako.shortcuts import render_to_string
|
||||
|
||||
|
||||
def edxnotes(cls):
|
||||
"""
|
||||
Decorator that makes components annotatable.
|
||||
"""
|
||||
original_get_html = cls.get_html
|
||||
|
||||
def get_html(self, *args, **kwargs):
|
||||
"""
|
||||
Returns raw html for the component.
|
||||
"""
|
||||
is_studio = getattr(self.system, "is_author_mode", False)
|
||||
course = self.descriptor.runtime.modulestore.get_course(self.runtime.course_id)
|
||||
|
||||
# Must be disabled:
|
||||
# - in Studio;
|
||||
# - when Harvard Annotation Tool is enabled for the course;
|
||||
# - when the feature flag or `edxnotes` setting of the course is set to False.
|
||||
if is_studio or not is_feature_enabled(course):
|
||||
return original_get_html(self, *args, **kwargs)
|
||||
else:
|
||||
return render_to_string("edxnotes_wrapper.html", {
|
||||
"content": original_get_html(self, *args, **kwargs),
|
||||
"uid": generate_uid(),
|
||||
"edxnotes_visibility": json.dumps(
|
||||
getattr(self, 'edxnotes_visibility', course.edxnotes_visibility)
|
||||
),
|
||||
"params": {
|
||||
# Use camelCase to name keys.
|
||||
"usageId": unicode(self.scope_ids.usage_id).encode("utf-8"),
|
||||
"courseId": unicode(self.runtime.course_id).encode("utf-8"),
|
||||
"token": get_id_token(self.runtime.get_real_user(self.runtime.anonymous_student_id)),
|
||||
"tokenUrl": get_token_url(self.runtime.course_id),
|
||||
"endpoint": get_endpoint(),
|
||||
"debug": settings.DEBUG,
|
||||
},
|
||||
})
|
||||
|
||||
cls.get_html = get_html
|
||||
return cls
|
||||
17
lms/djangoapps/edxnotes/exceptions.py
Normal file
17
lms/djangoapps/edxnotes/exceptions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Exceptions related to EdxNotes.
|
||||
"""
|
||||
|
||||
|
||||
class EdxNotesParseError(Exception):
|
||||
"""
|
||||
An exception that is raised whenever we have issues with data parsing.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EdxNotesServiceUnavailable(Exception):
|
||||
"""
|
||||
An exception that is raised whenever EdxNotes service is unavailable.
|
||||
"""
|
||||
pass
|
||||
401
lms/djangoapps/edxnotes/helpers.py
Normal file
401
lms/djangoapps/edxnotes/helpers.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Helper methods related to EdxNotes.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException
|
||||
from uuid import uuid4
|
||||
from json import JSONEncoder
|
||||
from datetime import datetime
|
||||
from courseware.access import has_access
|
||||
from courseware.views import get_current_child
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from capa.util import sanitize_html
|
||||
from student.models import anonymous_id_for_user
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from util.date_utils import get_default_time_display
|
||||
from dateutil.parser import parse as dateutil_parse
|
||||
from provider.oauth2.models import AccessToken, Client
|
||||
import oauth2_provider.oidc as oidc
|
||||
from provider.utils import now
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from .exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
HIGHLIGHT_TAG = "span"
|
||||
HIGHLIGHT_CLASS = "note-highlight"
|
||||
|
||||
|
||||
class NoteJSONEncoder(JSONEncoder):
|
||||
"""
|
||||
Custom JSON encoder that encode datetime objects to appropriate time strings.
|
||||
"""
|
||||
# pylint: disable=method-hidden
|
||||
def default(self, obj):
|
||||
if isinstance(obj, datetime):
|
||||
return get_default_time_display(obj)
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
def get_id_token(user):
|
||||
"""
|
||||
Generates JWT ID-Token, using or creating user's OAuth access token.
|
||||
"""
|
||||
try:
|
||||
client = Client.objects.get(name="edx-notes")
|
||||
except Client.DoesNotExist:
|
||||
raise ImproperlyConfigured("OAuth2 Client with name 'edx-notes' is not present in the DB")
|
||||
try:
|
||||
access_token = AccessToken.objects.get(
|
||||
client=client,
|
||||
user=user,
|
||||
expires__gt=now()
|
||||
)
|
||||
except AccessToken.DoesNotExist:
|
||||
access_token = AccessToken(client=client, user=user)
|
||||
access_token.save()
|
||||
|
||||
id_token = oidc.id_token(access_token)
|
||||
secret = id_token.access_token.client.client_secret
|
||||
return id_token.encode(secret)
|
||||
|
||||
|
||||
def get_token_url(course_id):
|
||||
"""
|
||||
Returns token url for the course.
|
||||
"""
|
||||
return reverse("get_token", kwargs={
|
||||
"course_id": unicode(course_id),
|
||||
})
|
||||
|
||||
|
||||
def send_request(user, course_id, path="", query_string=None):
|
||||
"""
|
||||
Sends a request with appropriate parameters and headers.
|
||||
"""
|
||||
url = get_endpoint(path)
|
||||
params = {
|
||||
"user": anonymous_id_for_user(user, None),
|
||||
"course_id": unicode(course_id).encode("utf-8"),
|
||||
}
|
||||
|
||||
if query_string:
|
||||
params.update({
|
||||
"text": query_string,
|
||||
"highlight": True,
|
||||
"highlight_tag": HIGHLIGHT_TAG,
|
||||
"highlight_class": HIGHLIGHT_CLASS,
|
||||
})
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={
|
||||
"x-annotator-auth-token": get_id_token(user)
|
||||
},
|
||||
params=params
|
||||
)
|
||||
except RequestException:
|
||||
raise EdxNotesServiceUnavailable(_("EdxNotes Service is unavailable. Please try again in a few minutes."))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def get_parent_xblock(xblock):
|
||||
"""
|
||||
Returns the xblock that is the parent of the specified xblock, or None if it has no parent.
|
||||
"""
|
||||
# TODO: replace with xblock.get_parent() when it lands
|
||||
store = modulestore()
|
||||
if store.request_cache is not None:
|
||||
parent_cache = store.request_cache.data.setdefault('edxnotes-parent-cache', {})
|
||||
else:
|
||||
parent_cache = None
|
||||
|
||||
locator = xblock.location
|
||||
if parent_cache and unicode(locator) in parent_cache:
|
||||
return parent_cache[unicode(locator)]
|
||||
|
||||
parent_location = store.get_parent_location(locator)
|
||||
|
||||
if parent_location is None:
|
||||
return None
|
||||
xblock = store.get_item(parent_location)
|
||||
# .get_parent_location(locator) returns locators w/o branch and version
|
||||
# and for uniformity we remove them from children locators
|
||||
xblock.children = [child.for_branch(None) for child in xblock.children]
|
||||
|
||||
if parent_cache is not None:
|
||||
for child in xblock.children:
|
||||
parent_cache[unicode(child)] = xblock
|
||||
|
||||
return xblock
|
||||
|
||||
|
||||
def get_parent_unit(xblock):
|
||||
"""
|
||||
Find vertical that is a unit, not just some container.
|
||||
"""
|
||||
while xblock:
|
||||
xblock = get_parent_xblock(xblock)
|
||||
if xblock is None:
|
||||
return None
|
||||
parent = get_parent_xblock(xblock)
|
||||
if parent is None:
|
||||
return None
|
||||
if parent.category == 'sequential':
|
||||
return xblock
|
||||
|
||||
|
||||
def preprocess_collection(user, course, collection):
|
||||
"""
|
||||
Prepare `collection(notes_list)` provided by edx-notes-api
|
||||
for rendering in a template:
|
||||
add information about ancestor blocks,
|
||||
convert "updated" to date
|
||||
|
||||
Raises:
|
||||
ItemNotFoundError - when appropriate module is not found.
|
||||
"""
|
||||
# pylint: disable=too-many-statements
|
||||
|
||||
store = modulestore()
|
||||
filtered_collection = list()
|
||||
cache = {}
|
||||
with store.bulk_operations(course.id):
|
||||
for model in collection:
|
||||
model.update({
|
||||
u"text": sanitize_html(model["text"]),
|
||||
u"quote": sanitize_html(model["quote"]),
|
||||
u"updated": dateutil_parse(model["updated"]),
|
||||
})
|
||||
usage_id = model["usage_id"]
|
||||
if usage_id in cache:
|
||||
model.update(cache[usage_id])
|
||||
filtered_collection.append(model)
|
||||
continue
|
||||
|
||||
usage_key = UsageKey.from_string(usage_id)
|
||||
# Add a course run if necessary.
|
||||
usage_key = usage_key.replace(course_key=store.fill_in_run(usage_key.course_key))
|
||||
|
||||
try:
|
||||
item = store.get_item(usage_key)
|
||||
except ItemNotFoundError:
|
||||
log.debug("Module not found: %s", usage_key)
|
||||
continue
|
||||
|
||||
if not has_access(user, "load", item, course_key=course.id):
|
||||
log.debug("User %s does not have an access to %s", user, item)
|
||||
continue
|
||||
|
||||
unit = get_parent_unit(item)
|
||||
if unit is None:
|
||||
log.debug("Unit not found: %s", usage_key)
|
||||
continue
|
||||
|
||||
section = get_parent_xblock(unit)
|
||||
if not section:
|
||||
log.debug("Section not found: %s", usage_key)
|
||||
continue
|
||||
if section in cache:
|
||||
usage_context = cache[section]
|
||||
usage_context.update({
|
||||
"unit": get_module_context(course, unit),
|
||||
})
|
||||
model.update(usage_context)
|
||||
cache[usage_id] = cache[unit] = usage_context
|
||||
filtered_collection.append(model)
|
||||
continue
|
||||
|
||||
chapter = get_parent_xblock(section)
|
||||
if not chapter:
|
||||
log.debug("Chapter not found: %s", usage_key)
|
||||
continue
|
||||
if chapter in cache:
|
||||
usage_context = cache[chapter]
|
||||
usage_context.update({
|
||||
"unit": get_module_context(course, unit),
|
||||
"section": get_module_context(course, section),
|
||||
})
|
||||
model.update(usage_context)
|
||||
cache[usage_id] = cache[unit] = cache[section] = usage_context
|
||||
filtered_collection.append(model)
|
||||
continue
|
||||
|
||||
usage_context = {
|
||||
"unit": get_module_context(course, unit),
|
||||
"section": get_module_context(course, section),
|
||||
"chapter": get_module_context(course, chapter),
|
||||
}
|
||||
model.update(usage_context)
|
||||
cache[usage_id] = cache[unit] = cache[section] = cache[chapter] = usage_context
|
||||
filtered_collection.append(model)
|
||||
|
||||
return filtered_collection
|
||||
|
||||
|
||||
def get_module_context(course, item):
|
||||
"""
|
||||
Returns dispay_name and url for the parent module.
|
||||
"""
|
||||
item_dict = {
|
||||
'location': unicode(item.location),
|
||||
'display_name': item.display_name_with_default,
|
||||
}
|
||||
if item.category == 'chapter':
|
||||
item_dict['index'] = get_index(item_dict['location'], course.children)
|
||||
elif item.category == 'vertical':
|
||||
section = get_parent_xblock(item)
|
||||
chapter = get_parent_xblock(section)
|
||||
# Position starts from 1, that's why we add 1.
|
||||
position = get_index(unicode(item.location), section.children) + 1
|
||||
item_dict['url'] = reverse('courseware_position', kwargs={
|
||||
'course_id': unicode(course.id),
|
||||
'chapter': chapter.url_name,
|
||||
'section': section.url_name,
|
||||
'position': position,
|
||||
})
|
||||
if item.category in ('chapter', 'sequential'):
|
||||
item_dict['children'] = [unicode(child) for child in item.children]
|
||||
|
||||
return item_dict
|
||||
|
||||
|
||||
def get_index(usage_key, children):
|
||||
"""
|
||||
Returns an index of the child with `usage_key`.
|
||||
"""
|
||||
children = [unicode(child) for child in children]
|
||||
return children.index(usage_key)
|
||||
|
||||
|
||||
def search(user, course, query_string):
|
||||
"""
|
||||
Returns search results for the `query_string(str)`.
|
||||
"""
|
||||
response = send_request(user, course.id, "search", query_string)
|
||||
try:
|
||||
content = json.loads(response.content)
|
||||
collection = content["rows"]
|
||||
except (ValueError, KeyError):
|
||||
log.warning("invalid JSON: %s", response.content)
|
||||
raise EdxNotesParseError(_("Server error. Please try again in a few minutes."))
|
||||
|
||||
content.update({
|
||||
"rows": preprocess_collection(user, course, collection)
|
||||
})
|
||||
|
||||
return json.dumps(content, cls=NoteJSONEncoder)
|
||||
|
||||
|
||||
def get_notes(user, course):
|
||||
"""
|
||||
Returns all notes for the user.
|
||||
"""
|
||||
response = send_request(user, course.id, "annotations")
|
||||
try:
|
||||
collection = json.loads(response.content)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not collection:
|
||||
return None
|
||||
|
||||
return json.dumps(preprocess_collection(user, course, collection), cls=NoteJSONEncoder)
|
||||
|
||||
|
||||
def get_endpoint(path=""):
|
||||
"""
|
||||
Returns edx-notes-api endpoint.
|
||||
"""
|
||||
try:
|
||||
url = settings.EDXNOTES_INTERFACE['url']
|
||||
if not url.endswith("/"):
|
||||
url += "/"
|
||||
|
||||
if path:
|
||||
if path.startswith("/"):
|
||||
path = path.lstrip("/")
|
||||
if not path.endswith("/"):
|
||||
path += "/"
|
||||
|
||||
return url + path
|
||||
except (AttributeError, KeyError):
|
||||
raise ImproperlyConfigured(_("No endpoint was provided for EdxNotes."))
|
||||
|
||||
|
||||
def get_course_position(course_module):
|
||||
"""
|
||||
Return the user's current place in the course.
|
||||
|
||||
If this is the user's first time, leads to COURSE/CHAPTER/SECTION.
|
||||
If this isn't the users's first time, leads to COURSE/CHAPTER.
|
||||
|
||||
If there is no current position in the course or chapter, then selects
|
||||
the first child.
|
||||
"""
|
||||
urlargs = {'course_id': unicode(course_module.id)}
|
||||
chapter = get_current_child(course_module, min_depth=1)
|
||||
if chapter is None:
|
||||
log.debug("No chapter found when loading current position in course")
|
||||
return None
|
||||
|
||||
urlargs['chapter'] = chapter.url_name
|
||||
if course_module.position is not None:
|
||||
return {
|
||||
'display_name': chapter.display_name_with_default,
|
||||
'url': reverse('courseware_chapter', kwargs=urlargs),
|
||||
}
|
||||
|
||||
# Relying on default of returning first child
|
||||
section = get_current_child(chapter, min_depth=1)
|
||||
if section is None:
|
||||
log.debug("No section found when loading current position in course")
|
||||
return None
|
||||
|
||||
urlargs['section'] = section.url_name
|
||||
return {
|
||||
'display_name': section.display_name_with_default,
|
||||
'url': reverse('courseware_section', kwargs=urlargs)
|
||||
}
|
||||
|
||||
|
||||
def generate_uid():
|
||||
"""
|
||||
Generates unique id.
|
||||
"""
|
||||
return uuid4().int # pylint: disable=no-member
|
||||
|
||||
|
||||
def is_feature_enabled(course):
|
||||
"""
|
||||
Returns True if Student Notes feature is enabled for the course,
|
||||
False otherwise.
|
||||
|
||||
In order for the application to be enabled it must be:
|
||||
1) enabled globally via FEATURES.
|
||||
2) present in the course tab configuration.
|
||||
3) Harvard Annotation Tool must be disabled for the course.
|
||||
"""
|
||||
return (settings.FEATURES.get("ENABLE_EDXNOTES")
|
||||
and [t for t in course.tabs if t["type"] == "edxnotes"] # tab found
|
||||
and not is_harvard_notes_enabled(course))
|
||||
|
||||
|
||||
def is_harvard_notes_enabled(course):
|
||||
"""
|
||||
Returns True if Harvard Annotation Tool is enabled for the course,
|
||||
False otherwise.
|
||||
|
||||
Checks for 'textannotation', 'imageannotation', 'videoannotation' in the list
|
||||
of advanced modules of the course.
|
||||
"""
|
||||
modules = set(['textannotation', 'imageannotation', 'videoannotation'])
|
||||
return bool(modules.intersection(course.advanced_modules))
|
||||
975
lms/djangoapps/edxnotes/tests.py
Normal file
975
lms/djangoapps/edxnotes/tests.py
Normal file
@@ -0,0 +1,975 @@
|
||||
"""
|
||||
Tests for the EdxNotes app.
|
||||
"""
|
||||
import json
|
||||
import jwt
|
||||
from mock import patch, MagicMock
|
||||
from unittest import skipUnless
|
||||
from datetime import datetime
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from edxnotes import helpers
|
||||
from edxnotes.decorators import edxnotes
|
||||
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from oauth2_provider.tests.factories import ClientFactory
|
||||
from provider.oauth2.models import Client
|
||||
from xmodule.tabs import EdxNotesTab
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from courseware.model_data import FieldDataCache
|
||||
from courseware.module_render import get_module_for_descriptor
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
def enable_edxnotes_for_the_course(course, user_id):
|
||||
"""
|
||||
Enable EdxNotes for the course.
|
||||
"""
|
||||
course.tabs.append(EdxNotesTab())
|
||||
modulestore().update_item(course, user_id)
|
||||
|
||||
|
||||
@edxnotes
|
||||
class TestProblem(object):
|
||||
"""
|
||||
Test class (fake problem) decorated by edxnotes decorator.
|
||||
|
||||
The purpose of this class is to imitate any problem.
|
||||
"""
|
||||
def __init__(self, course):
|
||||
self.system = MagicMock(is_author_mode=False)
|
||||
self.scope_ids = MagicMock(usage_id="test_usage_id")
|
||||
self.user = UserFactory.create(username="Joe", email="joe@example.com", password="edx")
|
||||
self.runtime = MagicMock(course_id=course.id, get_real_user=lambda anon_id: self.user)
|
||||
self.descriptor = MagicMock()
|
||||
self.descriptor.runtime.modulestore.get_course.return_value = course
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Imitate get_html in module.
|
||||
"""
|
||||
return "original_get_html"
|
||||
|
||||
|
||||
@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.")
|
||||
class EdxNotesDecoratorTest(TestCase):
|
||||
"""
|
||||
Tests for edxnotes decorator.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
ClientFactory(name="edx-notes")
|
||||
self.course = CourseFactory.create(edxnotes=True)
|
||||
self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
self.problem = TestProblem(self.course)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': True})
|
||||
@patch("edxnotes.decorators.get_endpoint")
|
||||
@patch("edxnotes.decorators.get_token_url")
|
||||
@patch("edxnotes.decorators.get_id_token")
|
||||
@patch("edxnotes.decorators.generate_uid")
|
||||
def test_edxnotes_enabled(self, mock_generate_uid, mock_get_id_token, mock_get_token_url, mock_get_endpoint):
|
||||
"""
|
||||
Tests if get_html is wrapped when feature flag is on and edxnotes are
|
||||
enabled for the course.
|
||||
"""
|
||||
mock_generate_uid.return_value = "uid"
|
||||
mock_get_id_token.return_value = "token"
|
||||
mock_get_token_url.return_value = "/tokenUrl"
|
||||
mock_get_endpoint.return_value = "/endpoint"
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
expected_context = {
|
||||
"content": "original_get_html",
|
||||
"uid": "uid",
|
||||
"edxnotes_visibility": "true",
|
||||
"params": {
|
||||
"usageId": u"test_usage_id",
|
||||
"courseId": unicode(self.course.id).encode("utf-8"),
|
||||
"token": "token",
|
||||
"tokenUrl": "/tokenUrl",
|
||||
"endpoint": "/endpoint",
|
||||
"debug": settings.DEBUG,
|
||||
},
|
||||
}
|
||||
self.assertEqual(
|
||||
self.problem.get_html(),
|
||||
render_to_string("edxnotes_wrapper.html", expected_context),
|
||||
)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_edxnotes_disabled_if_edxnotes_flag_is_false(self):
|
||||
"""
|
||||
Tests that get_html is wrapped when feature flag is on, but edxnotes are
|
||||
disabled for the course.
|
||||
"""
|
||||
self.assertEqual("original_get_html", self.problem.get_html())
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False})
|
||||
def test_edxnotes_disabled(self):
|
||||
"""
|
||||
Tests that get_html is not wrapped when feature flag is off.
|
||||
"""
|
||||
self.assertEqual("original_get_html", self.problem.get_html())
|
||||
|
||||
def test_edxnotes_studio(self):
|
||||
"""
|
||||
Tests that get_html is not wrapped when problem is rendered in Studio.
|
||||
"""
|
||||
self.problem.system.is_author_mode = True
|
||||
self.assertEqual("original_get_html", self.problem.get_html())
|
||||
|
||||
def test_edxnotes_harvard_notes_enabled(self):
|
||||
"""
|
||||
Tests that get_html is not wrapped when Harvard Annotation Tool is enabled.
|
||||
"""
|
||||
self.course.advanced_modules = ["videoannotation", "imageannotation", "textannotation"]
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
self.assertEqual("original_get_html", self.problem.get_html())
|
||||
|
||||
|
||||
@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.")
|
||||
class EdxNotesHelpersTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for EdxNotes helpers.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup a dummy course content.
|
||||
"""
|
||||
super(EdxNotesHelpersTest, self).setUp()
|
||||
modulestore().request_cache.data = {}
|
||||
ClientFactory(name="edx-notes")
|
||||
self.course = CourseFactory.create()
|
||||
self.chapter = ItemFactory.create(category="chapter", parent_location=self.course.location)
|
||||
self.chapter_2 = ItemFactory.create(category="chapter", parent_location=self.course.location)
|
||||
self.sequential = ItemFactory.create(category="sequential", parent_location=self.chapter.location)
|
||||
self.vertical = ItemFactory.create(category="vertical", parent_location=self.sequential.location)
|
||||
self.html_module_1 = ItemFactory.create(category="html", parent_location=self.vertical.location)
|
||||
self.html_module_2 = ItemFactory.create(category="html", parent_location=self.vertical.location)
|
||||
self.vertical_with_container = ItemFactory.create(category='vertical', parent_location=self.sequential.location)
|
||||
self.child_container = ItemFactory.create(
|
||||
category='split_test', parent_location=self.vertical_with_container.location)
|
||||
self.child_vertical = ItemFactory.create(category='vertical', parent_location=self.child_container.location)
|
||||
self.child_html_module = ItemFactory.create(category="html", parent_location=self.child_vertical.location)
|
||||
|
||||
# Read again so that children lists are accurate
|
||||
self.course = self.store.get_item(self.course.location)
|
||||
self.chapter = self.store.get_item(self.chapter.location)
|
||||
self.chapter_2 = self.store.get_item(self.chapter_2.location)
|
||||
self.sequential = self.store.get_item(self.sequential.location)
|
||||
self.vertical = self.store.get_item(self.vertical.location)
|
||||
|
||||
self.vertical_with_container = self.store.get_item(self.vertical_with_container.location)
|
||||
self.child_container = self.store.get_item(self.child_container.location)
|
||||
self.child_vertical = self.store.get_item(self.child_vertical.location)
|
||||
self.child_html_module = self.store.get_item(self.child_html_module.location)
|
||||
|
||||
self.user = UserFactory.create(username="Joe", email="joe@example.com", password="edx")
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
|
||||
def _get_unit_url(self, course, chapter, section, position=1):
|
||||
"""
|
||||
Returns `jump_to_id` url for the `vertical`.
|
||||
"""
|
||||
return reverse('courseware_position', kwargs={
|
||||
'course_id': course.id,
|
||||
'chapter': chapter.url_name,
|
||||
'section': section.url_name,
|
||||
'position': position,
|
||||
})
|
||||
|
||||
def test_edxnotes_not_enabled(self):
|
||||
"""
|
||||
Tests that edxnotes are disabled when the course tab configuration does NOT
|
||||
contain a tab with type "edxnotes."
|
||||
"""
|
||||
self.course.tabs = []
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
def test_edxnotes_harvard_notes_enabled(self):
|
||||
"""
|
||||
Tests that edxnotes are disabled when Harvard Annotation Tool is enabled.
|
||||
"""
|
||||
self.course.advanced_modules = ["foo", "imageannotation", "boo"]
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
self.course.advanced_modules = ["foo", "boo", "videoannotation"]
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
self.course.advanced_modules = ["textannotation", "foo", "boo"]
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
self.course.advanced_modules = ["textannotation", "videoannotation", "imageannotation"]
|
||||
self.assertFalse(helpers.is_feature_enabled(self.course))
|
||||
|
||||
def test_edxnotes_enabled(self):
|
||||
"""
|
||||
Tests that edxnotes are enabled when the course tab configuration contains
|
||||
a tab with type "edxnotes."
|
||||
"""
|
||||
self.course.tabs = [{"type": "foo"},
|
||||
{"name": "Notes", "type": "edxnotes"},
|
||||
{"type": "bar"}]
|
||||
self.assertTrue(helpers.is_feature_enabled(self.course))
|
||||
|
||||
def test_get_endpoint(self):
|
||||
"""
|
||||
Tests that storage_url method returns appropriate values.
|
||||
"""
|
||||
# url ends with "/"
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com/"}):
|
||||
self.assertEqual("http://example.com/", helpers.get_endpoint())
|
||||
|
||||
# url doesn't have "/" at the end
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}):
|
||||
self.assertEqual("http://example.com/", helpers.get_endpoint())
|
||||
|
||||
# url with path that starts with "/"
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}):
|
||||
self.assertEqual("http://example.com/some_path/", helpers.get_endpoint("/some_path"))
|
||||
|
||||
# url with path without "/"
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"}):
|
||||
self.assertEqual("http://example.com/some_path/", helpers.get_endpoint("some_path/"))
|
||||
|
||||
# url is not configured
|
||||
with patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": None}):
|
||||
self.assertRaises(ImproperlyConfigured, helpers.get_endpoint)
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_get_notes_correct_data(self, mock_get):
|
||||
"""
|
||||
Tests the result if correct data is received.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps([
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(),
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(),
|
||||
}
|
||||
])
|
||||
|
||||
self.assertItemsEqual(
|
||||
[
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": "Nov 19, 2014 at 08:06 UTC",
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [
|
||||
unicode(self.vertical.location),
|
||||
unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": "Nov 19, 2014 at 08:05 UTC",
|
||||
},
|
||||
],
|
||||
json.loads(helpers.get_notes(self.user, self.course))
|
||||
)
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_get_notes_json_error(self, mock_get):
|
||||
"""
|
||||
Tests the result if incorrect json is received.
|
||||
"""
|
||||
mock_get.return_value.content = "Error"
|
||||
self.assertIsNone(helpers.get_notes(self.user, self.course))
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_get_notes_empty_collection(self, mock_get):
|
||||
"""
|
||||
Tests the result if an empty collection is received.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps([])
|
||||
self.assertIsNone(helpers.get_notes(self.user, self.course))
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_search_correct_data(self, mock_get):
|
||||
"""
|
||||
Tests the result if correct data is received.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps({
|
||||
"total": 2,
|
||||
"rows": [
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(),
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(),
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
self.assertItemsEqual(
|
||||
{
|
||||
"total": 2,
|
||||
"rows": [
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [
|
||||
unicode(self.vertical.location),
|
||||
unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": "Nov 19, 2014 at 08:06 UTC",
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [
|
||||
unicode(self.vertical.location),
|
||||
unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": "Nov 19, 2014 at 08:05 UTC",
|
||||
},
|
||||
]
|
||||
},
|
||||
json.loads(helpers.search(self.user, self.course, "test"))
|
||||
)
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_search_json_error(self, mock_get):
|
||||
"""
|
||||
Tests the result if incorrect json is received.
|
||||
"""
|
||||
mock_get.return_value.content = "Error"
|
||||
self.assertRaises(EdxNotesParseError, helpers.search, self.user, self.course, "test")
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_search_wrong_data_format(self, mock_get):
|
||||
"""
|
||||
Tests the result if incorrect data structure is received.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps({"1": 2})
|
||||
self.assertRaises(EdxNotesParseError, helpers.search, self.user, self.course, "test")
|
||||
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_search_empty_collection(self, mock_get):
|
||||
"""
|
||||
Tests no results.
|
||||
"""
|
||||
mock_get.return_value.content = json.dumps({
|
||||
"total": 0,
|
||||
"rows": []
|
||||
})
|
||||
self.assertItemsEqual(
|
||||
{
|
||||
"total": 0,
|
||||
"rows": []
|
||||
},
|
||||
json.loads(helpers.search(self.user, self.course, "test"))
|
||||
)
|
||||
|
||||
def test_preprocess_collection_escaping(self):
|
||||
"""
|
||||
Tests the result if appropriate module is not found.
|
||||
"""
|
||||
initial_collection = [{
|
||||
u"quote": u"test <script>alert('test')</script>",
|
||||
u"text": u"text \"<>&'",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat()
|
||||
}]
|
||||
|
||||
self.assertItemsEqual(
|
||||
[{
|
||||
u"quote": u"test <script>alert('test')</script>",
|
||||
u"text": u'text "<>&\'',
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000),
|
||||
}],
|
||||
helpers.preprocess_collection(self.user, self.course, initial_collection)
|
||||
)
|
||||
|
||||
def test_preprocess_collection_no_item(self):
|
||||
"""
|
||||
Tests the result if appropriate module is not found.
|
||||
"""
|
||||
initial_collection = [
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat()
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.course.id.make_usage_key("html", "test_item")),
|
||||
u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat()
|
||||
},
|
||||
]
|
||||
|
||||
self.assertItemsEqual(
|
||||
[{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000),
|
||||
}],
|
||||
helpers.preprocess_collection(self.user, self.course, initial_collection)
|
||||
)
|
||||
|
||||
def test_preprocess_collection_has_access(self):
|
||||
"""
|
||||
Tests the result if the user does not have access to some of the modules.
|
||||
"""
|
||||
initial_collection = [
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(),
|
||||
},
|
||||
{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_2.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(),
|
||||
},
|
||||
]
|
||||
self.html_module_2.visible_to_staff_only = True
|
||||
self.store.update_item(self.html_module_2, self.user.id)
|
||||
self.assertItemsEqual(
|
||||
[{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"chapter": {
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)]
|
||||
},
|
||||
u"section": {
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)]
|
||||
},
|
||||
u"unit": {
|
||||
u"url": self._get_unit_url(self.course, self.chapter, self.sequential),
|
||||
u"display_name": self.vertical.display_name_with_default,
|
||||
u"location": unicode(self.vertical.location),
|
||||
},
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000),
|
||||
}],
|
||||
helpers.preprocess_collection(self.user, self.course, initial_collection)
|
||||
)
|
||||
|
||||
@patch("edxnotes.helpers.has_access")
|
||||
@patch("edxnotes.helpers.modulestore")
|
||||
def test_preprocess_collection_no_unit(self, mock_modulestore, mock_has_access):
|
||||
"""
|
||||
Tests the result if the unit does not exist.
|
||||
"""
|
||||
store = MagicMock()
|
||||
store.get_parent_location.return_value = None
|
||||
mock_modulestore.return_value = store
|
||||
mock_has_access.return_value = True
|
||||
initial_collection = [{
|
||||
u"quote": u"quote text",
|
||||
u"text": u"text",
|
||||
u"usage_id": unicode(self.html_module_1.location),
|
||||
u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(),
|
||||
}]
|
||||
|
||||
self.assertItemsEqual(
|
||||
[], helpers.preprocess_collection(self.user, self.course, initial_collection)
|
||||
)
|
||||
|
||||
def test_get_parent_xblock(self):
|
||||
"""
|
||||
Tests `get_parent_xblock` method to return parent xblock or None
|
||||
"""
|
||||
for _ in range(2):
|
||||
# repeat the test twice to make sure caching does not interfere
|
||||
self.assertEqual(helpers.get_parent_xblock(self.html_module_1).location, self.vertical.location)
|
||||
self.assertEqual(helpers.get_parent_xblock(self.sequential).location, self.chapter.location)
|
||||
self.assertEqual(helpers.get_parent_xblock(self.chapter).location, self.course.location)
|
||||
self.assertIsNone(helpers.get_parent_xblock(self.course))
|
||||
|
||||
def test_get_parent_unit(self):
|
||||
"""
|
||||
Tests `get_parent_unit` method for the successful result.
|
||||
"""
|
||||
parent = helpers.get_parent_unit(self.html_module_1)
|
||||
self.assertEqual(parent.location, self.vertical.location)
|
||||
|
||||
parent = helpers.get_parent_unit(self.child_html_module)
|
||||
self.assertEqual(parent.location, self.vertical_with_container.location)
|
||||
|
||||
self.assertIsNone(helpers.get_parent_unit(None))
|
||||
self.assertIsNone(helpers.get_parent_unit(self.course))
|
||||
self.assertIsNone(helpers.get_parent_unit(self.chapter))
|
||||
self.assertIsNone(helpers.get_parent_unit(self.sequential))
|
||||
|
||||
def test_get_module_context_sequential(self):
|
||||
"""
|
||||
Tests `get_module_context` method for the sequential.
|
||||
"""
|
||||
self.assertDictEqual(
|
||||
{
|
||||
u"display_name": self.sequential.display_name_with_default,
|
||||
u"location": unicode(self.sequential.location),
|
||||
u"children": [unicode(self.vertical.location), unicode(self.vertical_with_container.location)],
|
||||
},
|
||||
helpers.get_module_context(self.course, self.sequential)
|
||||
)
|
||||
|
||||
def test_get_module_context_html_component(self):
|
||||
"""
|
||||
Tests `get_module_context` method for the components.
|
||||
"""
|
||||
self.assertDictEqual(
|
||||
{
|
||||
u"display_name": self.html_module_1.display_name_with_default,
|
||||
u"location": unicode(self.html_module_1.location),
|
||||
},
|
||||
helpers.get_module_context(self.course, self.html_module_1)
|
||||
)
|
||||
|
||||
def test_get_module_context_chapter(self):
|
||||
"""
|
||||
Tests `get_module_context` method for the chapters.
|
||||
"""
|
||||
self.assertDictEqual(
|
||||
{
|
||||
u"display_name": self.chapter.display_name_with_default,
|
||||
u"index": 0,
|
||||
u"location": unicode(self.chapter.location),
|
||||
u"children": [unicode(self.sequential.location)],
|
||||
},
|
||||
helpers.get_module_context(self.course, self.chapter)
|
||||
)
|
||||
self.assertDictEqual(
|
||||
{
|
||||
u"display_name": self.chapter_2.display_name_with_default,
|
||||
u"index": 1,
|
||||
u"location": unicode(self.chapter_2.location),
|
||||
u"children": [],
|
||||
},
|
||||
helpers.get_module_context(self.course, self.chapter_2)
|
||||
)
|
||||
|
||||
@patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"})
|
||||
@patch("edxnotes.helpers.anonymous_id_for_user")
|
||||
@patch("edxnotes.helpers.get_id_token")
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_send_request_with_query_string(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user):
|
||||
"""
|
||||
Tests that requests are send with correct information.
|
||||
"""
|
||||
mock_get_id_token.return_value = "test_token"
|
||||
mock_anonymous_id_for_user.return_value = "anonymous_id"
|
||||
helpers.send_request(
|
||||
self.user, self.course.id, path="test", query_string="text"
|
||||
)
|
||||
mock_get.assert_called_with(
|
||||
"http://example.com/test/",
|
||||
headers={
|
||||
"x-annotator-auth-token": "test_token"
|
||||
},
|
||||
params={
|
||||
"user": "anonymous_id",
|
||||
"course_id": unicode(self.course.id),
|
||||
"text": "text",
|
||||
"highlight": True,
|
||||
"highlight_tag": "span",
|
||||
"highlight_class": "note-highlight",
|
||||
}
|
||||
)
|
||||
|
||||
@patch.dict("django.conf.settings.EDXNOTES_INTERFACE", {"url": "http://example.com"})
|
||||
@patch("edxnotes.helpers.anonymous_id_for_user")
|
||||
@patch("edxnotes.helpers.get_id_token")
|
||||
@patch("edxnotes.helpers.requests.get")
|
||||
def test_send_request_without_query_string(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user):
|
||||
"""
|
||||
Tests that requests are send with correct information.
|
||||
"""
|
||||
mock_get_id_token.return_value = "test_token"
|
||||
mock_anonymous_id_for_user.return_value = "anonymous_id"
|
||||
helpers.send_request(
|
||||
self.user, self.course.id, path="test"
|
||||
)
|
||||
mock_get.assert_called_with(
|
||||
"http://example.com/test/",
|
||||
headers={
|
||||
"x-annotator-auth-token": "test_token"
|
||||
},
|
||||
params={
|
||||
"user": "anonymous_id",
|
||||
"course_id": unicode(self.course.id),
|
||||
}
|
||||
)
|
||||
|
||||
def test_get_course_position_no_chapter(self):
|
||||
"""
|
||||
Returns `None` if no chapter found.
|
||||
"""
|
||||
mock_course_module = MagicMock()
|
||||
mock_course_module.position = 3
|
||||
mock_course_module.get_display_items.return_value = []
|
||||
self.assertIsNone(helpers.get_course_position(mock_course_module))
|
||||
|
||||
def test_get_course_position_to_chapter(self):
|
||||
"""
|
||||
Returns a position that leads to COURSE/CHAPTER if this isn't the users's
|
||||
first time.
|
||||
"""
|
||||
mock_course_module = MagicMock(id=self.course.id, position=3)
|
||||
|
||||
mock_chapter = MagicMock()
|
||||
mock_chapter.url_name = 'chapter_url_name'
|
||||
mock_chapter.display_name_with_default = 'Test Chapter Display Name'
|
||||
|
||||
mock_course_module.get_display_items.return_value = [mock_chapter]
|
||||
|
||||
self.assertEqual(helpers.get_course_position(mock_course_module), {
|
||||
'display_name': 'Test Chapter Display Name',
|
||||
'url': '/courses/{}/courseware/chapter_url_name/'.format(self.course.id),
|
||||
})
|
||||
|
||||
def test_get_course_position_no_section(self):
|
||||
"""
|
||||
Returns `None` if no section found.
|
||||
"""
|
||||
mock_course_module = MagicMock(id=self.course.id, position=None)
|
||||
mock_course_module.get_display_items.return_value = [MagicMock()]
|
||||
self.assertIsNone(helpers.get_course_position(mock_course_module))
|
||||
|
||||
def test_get_course_position_to_section(self):
|
||||
"""
|
||||
Returns a position that leads to COURSE/CHAPTER/SECTION if this is the
|
||||
user's first time.
|
||||
"""
|
||||
mock_course_module = MagicMock(id=self.course.id, position=None)
|
||||
|
||||
mock_chapter = MagicMock()
|
||||
mock_chapter.url_name = 'chapter_url_name'
|
||||
mock_course_module.get_display_items.return_value = [mock_chapter]
|
||||
|
||||
mock_section = MagicMock()
|
||||
mock_section.url_name = 'section_url_name'
|
||||
mock_section.display_name_with_default = 'Test Section Display Name'
|
||||
|
||||
mock_chapter.get_display_items.return_value = [mock_section]
|
||||
mock_section.get_display_items.return_value = [MagicMock()]
|
||||
|
||||
self.assertEqual(helpers.get_course_position(mock_course_module), {
|
||||
'display_name': 'Test Section Display Name',
|
||||
'url': '/courses/{}/courseware/chapter_url_name/section_url_name/'.format(self.course.id),
|
||||
})
|
||||
|
||||
def test_get_index(self):
|
||||
"""
|
||||
Tests `get_index` method returns unit url.
|
||||
"""
|
||||
children = self.sequential.children
|
||||
self.assertEqual(0, helpers.get_index(unicode(self.vertical.location), children))
|
||||
self.assertEqual(1, helpers.get_index(unicode(self.vertical_with_container.location), children))
|
||||
|
||||
|
||||
@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.")
|
||||
class EdxNotesViewsTest(TestCase):
|
||||
"""
|
||||
Tests for EdxNotes views.
|
||||
"""
|
||||
def setUp(self):
|
||||
ClientFactory(name="edx-notes")
|
||||
super(EdxNotesViewsTest, self).setUp()
|
||||
self.course = CourseFactory.create(edxnotes=True)
|
||||
self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
self.notes_page_url = reverse("edxnotes", args=[unicode(self.course.id)])
|
||||
self.search_url = reverse("search_notes", args=[unicode(self.course.id)])
|
||||
self.get_token_url = reverse("get_token", args=[unicode(self.course.id)])
|
||||
self.visibility_url = reverse("edxnotes_visibility", args=[unicode(self.course.id)])
|
||||
|
||||
def _get_course_module(self):
|
||||
"""
|
||||
Returns the course module.
|
||||
"""
|
||||
field_data_cache = FieldDataCache([self.course], self.course.id, self.user)
|
||||
return get_module_for_descriptor(self.user, MagicMock(), self.course, field_data_cache, self.course.id)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.get_notes", return_value=[])
|
||||
def test_edxnotes_view_is_enabled(self, mock_get_notes):
|
||||
"""
|
||||
Tests that appropriate view is received if EdxNotes feature is enabled.
|
||||
"""
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.notes_page_url)
|
||||
self.assertContains(response, 'Highlights and notes you\'ve made in course content')
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False})
|
||||
def test_edxnotes_view_is_disabled(self):
|
||||
"""
|
||||
Tests that 404 status code is received if EdxNotes feature is disabled.
|
||||
"""
|
||||
response = self.client.get(self.notes_page_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.get_notes")
|
||||
def test_edxnotes_view_404_service_unavailable(self, mock_get_notes):
|
||||
"""
|
||||
Tests that 404 status code is received if EdxNotes service is unavailable.
|
||||
"""
|
||||
mock_get_notes.side_effect = EdxNotesServiceUnavailable
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.notes_page_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_notes_successfully_respond(self, mock_search):
|
||||
"""
|
||||
Tests that `search_notes` successfully respond if EdxNotes feature is enabled.
|
||||
"""
|
||||
mock_search.return_value = json.dumps({
|
||||
"total": 0,
|
||||
"rows": [],
|
||||
})
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.search_url, {"text": "test"})
|
||||
self.assertEqual(json.loads(response.content), {
|
||||
"total": 0,
|
||||
"rows": [],
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_notes_is_disabled(self, mock_search):
|
||||
"""
|
||||
Tests that 404 status code is received if EdxNotes feature is disabled.
|
||||
"""
|
||||
mock_search.return_value = json.dumps({
|
||||
"total": 0,
|
||||
"rows": [],
|
||||
})
|
||||
response = self.client.get(self.search_url, {"text": "test"})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_404_service_unavailable(self, mock_search):
|
||||
"""
|
||||
Tests that 404 status code is received if EdxNotes service is unavailable.
|
||||
"""
|
||||
mock_search.side_effect = EdxNotesServiceUnavailable
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.search_url, {"text": "test"})
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertIn("error", response.content)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_notes_without_required_parameters(self, mock_search):
|
||||
"""
|
||||
Tests that 400 status code is received if the required parameters were not sent.
|
||||
"""
|
||||
mock_search.return_value = json.dumps({
|
||||
"total": 0,
|
||||
"rows": [],
|
||||
})
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.search_url)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
@patch("edxnotes.views.search")
|
||||
def test_search_notes_exception(self, mock_search):
|
||||
"""
|
||||
Tests that 500 status code is received if invalid data was received from
|
||||
EdXNotes service.
|
||||
"""
|
||||
mock_search.side_effect = EdxNotesParseError
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.get(self.search_url, {"text": "test"})
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertIn("error", response.content)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_get_id_token(self):
|
||||
"""
|
||||
Test generation of ID Token.
|
||||
"""
|
||||
response = self.client.get(self.get_token_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
client = Client.objects.get(name='edx-notes')
|
||||
jwt.decode(response.content, client.client_secret)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_get_id_token_anonymous(self):
|
||||
"""
|
||||
Test that generation of ID Token does not work for anonymous user.
|
||||
"""
|
||||
self.client.logout()
|
||||
response = self.client.get(self.get_token_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_edxnotes_visibility(self):
|
||||
"""
|
||||
Can update edxnotes_visibility value successfully.
|
||||
"""
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.post(
|
||||
self.visibility_url,
|
||||
data=json.dumps({"visibility": False}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
course_module = self._get_course_module()
|
||||
self.assertFalse(course_module.edxnotes_visibility)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False})
|
||||
def test_edxnotes_visibility_if_feature_is_disabled(self):
|
||||
"""
|
||||
Tests that 404 response is received if EdxNotes feature is disabled.
|
||||
"""
|
||||
response = self.client.post(self.visibility_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_edxnotes_visibility_invalid_json(self):
|
||||
"""
|
||||
Tests that 400 response is received if invalid JSON is sent.
|
||||
"""
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.post(
|
||||
self.visibility_url,
|
||||
data="string",
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True})
|
||||
def test_edxnotes_visibility_key_error(self):
|
||||
"""
|
||||
Tests that 400 response is received if invalid data structure is sent.
|
||||
"""
|
||||
enable_edxnotes_for_the_course(self.course, self.user.id)
|
||||
response = self.client.post(
|
||||
self.visibility_url,
|
||||
data=json.dumps({'test_key': 1}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
13
lms/djangoapps/edxnotes/urls.py
Normal file
13
lms/djangoapps/edxnotes/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
URLs for EdxNotes.
|
||||
"""
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
# Additionally, we include login URLs for the browseable API.
|
||||
urlpatterns = patterns(
|
||||
"edxnotes.views",
|
||||
url(r"^/$", "edxnotes", name="edxnotes"),
|
||||
url(r"^/search/$", "search_notes", name="search_notes"),
|
||||
url(r"^/token/$", "get_token", name="get_token"),
|
||||
url(r"^/visibility/$", "edxnotes_visibility", name="edxnotes_visibility"),
|
||||
)
|
||||
120
lms/djangoapps/edxnotes/views.py
Normal file
120
lms/djangoapps/edxnotes/views.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Views related to EdxNotes.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, Http404
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from courseware.courses import get_course_with_access
|
||||
from courseware.model_data import FieldDataCache
|
||||
from courseware.module_render import get_module_for_descriptor
|
||||
from util.json_request import JsonResponse, JsonResponseBadRequest
|
||||
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
|
||||
from edxnotes.helpers import (
|
||||
get_notes,
|
||||
get_id_token,
|
||||
is_feature_enabled,
|
||||
search,
|
||||
get_course_position,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@login_required
|
||||
def edxnotes(request, course_id):
|
||||
"""
|
||||
Displays the EdxNotes page.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = get_course_with_access(request.user, "load", course_key)
|
||||
|
||||
if not is_feature_enabled(course):
|
||||
raise Http404
|
||||
|
||||
try:
|
||||
notes = get_notes(request.user, course)
|
||||
except EdxNotesServiceUnavailable:
|
||||
raise Http404
|
||||
|
||||
context = {
|
||||
"course": course,
|
||||
"search_endpoint": reverse("search_notes", kwargs={"course_id": course_id}),
|
||||
"notes": notes,
|
||||
"debug": json.dumps(settings.DEBUG),
|
||||
'position': None,
|
||||
}
|
||||
|
||||
if not notes:
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
course.id, request.user, course, depth=2
|
||||
)
|
||||
course_module = get_module_for_descriptor(request.user, request, course, field_data_cache, course_key)
|
||||
position = get_course_position(course_module)
|
||||
if position:
|
||||
context.update({
|
||||
'position': position,
|
||||
})
|
||||
|
||||
return render_to_response("edxnotes/edxnotes.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def search_notes(request, course_id):
|
||||
"""
|
||||
Handles search requests.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = get_course_with_access(request.user, "load", course_key)
|
||||
|
||||
if not is_feature_enabled(course):
|
||||
raise Http404
|
||||
|
||||
if "text" not in request.GET:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
query_string = request.GET["text"]
|
||||
try:
|
||||
search_results = search(request.user, course, query_string)
|
||||
except (EdxNotesParseError, EdxNotesServiceUnavailable) as err:
|
||||
return JsonResponseBadRequest({"error": err.message}, status=500)
|
||||
|
||||
return HttpResponse(search_results)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@login_required
|
||||
def get_token(request, course_id):
|
||||
"""
|
||||
Get JWT ID-Token, in case you need new one.
|
||||
"""
|
||||
return HttpResponse(get_id_token(request.user), content_type='text/plain')
|
||||
|
||||
|
||||
@login_required
|
||||
def edxnotes_visibility(request, course_id):
|
||||
"""
|
||||
Handle ajax call from "Show notes" checkbox.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = get_course_with_access(request.user, "load", course_key)
|
||||
field_data_cache = FieldDataCache([course], course_key, request.user)
|
||||
course_module = get_module_for_descriptor(request.user, request, course, field_data_cache, course_key)
|
||||
|
||||
if not is_feature_enabled(course):
|
||||
raise Http404
|
||||
|
||||
try:
|
||||
visibility = json.loads(request.body)["visibility"]
|
||||
course_module.edxnotes_visibility = visibility
|
||||
course_module.save()
|
||||
return JsonResponse(status=200)
|
||||
except (ValueError, KeyError):
|
||||
log.warning(
|
||||
"Could not decode request body as JSON and find a boolean visibility field: '%s'", request.body
|
||||
)
|
||||
return JsonResponseBadRequest()
|
||||
Reference in New Issue
Block a user