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:
polesye
2014-10-23 13:06:24 +03:00
committed by Tim Babych
parent c11a9f056e
commit c7153be040
125 changed files with 9458 additions and 358 deletions

View File

View 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

View 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

View 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))

View 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 &lt;script&gt;alert('test')&lt;/script&gt;",
u"text": u'text "&lt;&gt;&amp;\'',
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)

View 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"),
)

View 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()