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:
330
common/djangoapps/terrain/stubs/edxnotes.py
Normal file
330
common/djangoapps/terrain/stubs/edxnotes.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Stub implementation of EdxNotes for acceptance tests
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
from copy import deepcopy
|
||||
|
||||
from .http import StubHttpRequestHandler, StubHttpService
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
class StubEdxNotesServiceHandler(StubHttpRequestHandler):
|
||||
"""
|
||||
Handler for EdxNotes requests.
|
||||
"""
|
||||
URL_HANDLERS = {
|
||||
"GET": {
|
||||
"/api/v1/annotations$": "_collection",
|
||||
"/api/v1/annotations/(?P<note_id>[0-9A-Fa-f]+)$": "_read",
|
||||
"/api/v1/search$": "_search",
|
||||
},
|
||||
"POST": {
|
||||
"/api/v1/annotations$": "_create",
|
||||
"/create_notes": "_create_notes",
|
||||
},
|
||||
"PUT": {
|
||||
"/api/v1/annotations/(?P<note_id>[0-9A-Fa-f]+)$": "_update",
|
||||
"/cleanup$": "_cleanup",
|
||||
},
|
||||
"DELETE": {
|
||||
"/api/v1/annotations/(?P<note_id>[0-9A-Fa-f]+)$": "_delete",
|
||||
},
|
||||
}
|
||||
|
||||
def _match_pattern(self, pattern_handlers):
|
||||
"""
|
||||
Finds handler by the provided handler patterns and delegate response to
|
||||
the matched handler.
|
||||
"""
|
||||
for pattern in pattern_handlers:
|
||||
match = re.match(pattern, self.path_only)
|
||||
if match:
|
||||
handler = getattr(self, pattern_handlers[pattern], None)
|
||||
if handler:
|
||||
handler(**match.groupdict())
|
||||
return True
|
||||
return None
|
||||
|
||||
def _send_handler_response(self, method):
|
||||
"""
|
||||
Delegate response to handler methods.
|
||||
If no handler defined, send a 404 response.
|
||||
"""
|
||||
# Choose the list of handlers based on the HTTP method
|
||||
if method in self.URL_HANDLERS:
|
||||
handlers_list = self.URL_HANDLERS[method]
|
||||
else:
|
||||
self.log_error("Unrecognized method '{method}'".format(method=method))
|
||||
return
|
||||
|
||||
# Check the path (without querystring params) against our list of handlers
|
||||
if self._match_pattern(handlers_list):
|
||||
return
|
||||
# If we don't have a handler for this URL and/or HTTP method,
|
||||
# respond with a 404.
|
||||
else:
|
||||
self.send_response(404, content="404 Not Found")
|
||||
|
||||
def do_GET(self):
|
||||
"""
|
||||
Handle GET methods to the EdxNotes API stub.
|
||||
"""
|
||||
self._send_handler_response("GET")
|
||||
|
||||
def do_POST(self):
|
||||
"""
|
||||
Handle POST methods to the EdxNotes API stub.
|
||||
"""
|
||||
self._send_handler_response("POST")
|
||||
|
||||
def do_PUT(self):
|
||||
"""
|
||||
Handle PUT methods to the EdxNotes API stub.
|
||||
"""
|
||||
if self.path.startswith("/set_config"):
|
||||
return StubHttpRequestHandler.do_PUT(self)
|
||||
|
||||
self._send_handler_response("PUT")
|
||||
|
||||
def do_DELETE(self):
|
||||
"""
|
||||
Handle DELETE methods to the EdxNotes API stub.
|
||||
"""
|
||||
self._send_handler_response("DELETE")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
"""
|
||||
Handle OPTIONS methods to the EdxNotes API stub.
|
||||
"""
|
||||
self.send_response(200, headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Length, Content-Type, X-Annotator-Auth-Token, X-Requested-With, X-Annotator-Auth-Token, X-Requested-With, X-CSRFToken",
|
||||
})
|
||||
|
||||
def respond(self, status_code=200, content=None):
|
||||
"""
|
||||
Send a response back to the client with the HTTP `status_code` (int),
|
||||
the given content serialized as JSON (str), and the headers set appropriately.
|
||||
"""
|
||||
headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
}
|
||||
if status_code < 400 and content:
|
||||
headers["Content-Type"] = "application/json"
|
||||
content = json.dumps(content)
|
||||
else:
|
||||
headers["Content-Type"] = "text/html"
|
||||
|
||||
self.send_response(status_code, content, headers)
|
||||
|
||||
def _create(self):
|
||||
"""
|
||||
Create a note, assign id, annotator_schema_version, created and updated dates.
|
||||
"""
|
||||
note = json.loads(self.request_content)
|
||||
note.update({
|
||||
"id": uuid4().hex,
|
||||
"annotator_schema_version": "v1.0",
|
||||
"created": datetime.utcnow().isoformat(),
|
||||
"updated": datetime.utcnow().isoformat(),
|
||||
})
|
||||
self.server.add_notes(note)
|
||||
self.respond(content=note)
|
||||
|
||||
def _create_notes(self):
|
||||
"""
|
||||
The same as self._create, but it works a list of notes.
|
||||
"""
|
||||
try:
|
||||
notes = json.loads(self.request_content)
|
||||
except ValueError:
|
||||
self.respond(400, "Bad Request")
|
||||
return
|
||||
|
||||
if not isinstance(notes, list):
|
||||
self.respond(400, "Bad Request")
|
||||
return
|
||||
|
||||
for note in notes:
|
||||
note.update({
|
||||
"id": uuid4().hex,
|
||||
"annotator_schema_version": "v1.0",
|
||||
"created": note["created"] if note.get("created") else datetime.utcnow().isoformat(),
|
||||
"updated": note["updated"] if note.get("updated") else datetime.utcnow().isoformat(),
|
||||
})
|
||||
self.server.add_notes(note)
|
||||
|
||||
self.respond(content=notes)
|
||||
|
||||
def _read(self, note_id):
|
||||
"""
|
||||
Return the note by note id.
|
||||
"""
|
||||
notes = self.server.get_notes()
|
||||
result = self.server.filter_by_id(notes, note_id)
|
||||
if result:
|
||||
self.respond(content=result[0])
|
||||
else:
|
||||
self.respond(404, "404 Not Found")
|
||||
|
||||
def _update(self, note_id):
|
||||
"""
|
||||
Update the note by note id.
|
||||
"""
|
||||
note = self.server.update_note(note_id, json.loads(self.request_content))
|
||||
if note:
|
||||
self.respond(content=note)
|
||||
else:
|
||||
self.respond(404, "404 Not Found")
|
||||
|
||||
def _delete(self, note_id):
|
||||
"""
|
||||
Delete the note by note id.
|
||||
"""
|
||||
if self.server.delete_note(note_id):
|
||||
self.respond(204, "No Content")
|
||||
else:
|
||||
self.respond(404, "404 Not Found")
|
||||
|
||||
def _search(self):
|
||||
"""
|
||||
Search for a notes by user id, course_id and usage_id.
|
||||
"""
|
||||
user = self.get_params.get("user", None)
|
||||
usage_id = self.get_params.get("usage_id", None)
|
||||
course_id = self.get_params.get("course_id", None)
|
||||
text = self.get_params.get("text", None)
|
||||
|
||||
if user is None:
|
||||
self.respond(400, "Bad Request")
|
||||
return
|
||||
|
||||
notes = self.server.get_notes()
|
||||
if course_id is not None:
|
||||
notes = self.server.filter_by_course_id(notes, course_id)
|
||||
if usage_id is not None:
|
||||
notes = self.server.filter_by_usage_id(notes, usage_id)
|
||||
if text:
|
||||
notes = self.server.search(notes, text)
|
||||
self.respond(content={
|
||||
"total": len(notes),
|
||||
"rows": notes,
|
||||
})
|
||||
|
||||
def _collection(self):
|
||||
"""
|
||||
Return all notes for the user.
|
||||
"""
|
||||
user = self.get_params.get("user", None)
|
||||
if user is None:
|
||||
self.send_response(400, content="Bad Request")
|
||||
return
|
||||
notes = self.server.get_notes()
|
||||
self.respond(content=notes)
|
||||
|
||||
def _cleanup(self):
|
||||
"""
|
||||
Helper method that removes all notes to the stub EdxNotes service.
|
||||
"""
|
||||
self.server.cleanup()
|
||||
self.respond()
|
||||
|
||||
|
||||
class StubEdxNotesService(StubHttpService):
|
||||
"""
|
||||
Stub EdxNotes service.
|
||||
"""
|
||||
HANDLER_CLASS = StubEdxNotesServiceHandler
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(StubEdxNotesService, self).__init__(*args, **kwargs)
|
||||
self.notes = list()
|
||||
|
||||
def get_notes(self):
|
||||
"""
|
||||
Returns a list of all notes.
|
||||
"""
|
||||
notes = deepcopy(self.notes)
|
||||
notes.reverse()
|
||||
return notes
|
||||
|
||||
def add_notes(self, notes):
|
||||
"""
|
||||
Adds `notes(list)` to the stub EdxNotes service.
|
||||
"""
|
||||
if not isinstance(notes, list):
|
||||
notes = [notes]
|
||||
|
||||
for note in notes:
|
||||
self.notes.append(note)
|
||||
|
||||
def update_note(self, note_id, note_info):
|
||||
"""
|
||||
Updates the note with `note_id(str)` by the `note_info(dict)` to the
|
||||
stub EdxNotes service.
|
||||
"""
|
||||
note = self.filter_by_id(self.notes, note_id)
|
||||
if note:
|
||||
note[0].update(note_info)
|
||||
return note
|
||||
else:
|
||||
return None
|
||||
|
||||
def delete_note(self, note_id):
|
||||
"""
|
||||
Removes the note with `note_id(str)` to the stub EdxNotes service.
|
||||
"""
|
||||
note = self.filter_by_id(self.notes, note_id)
|
||||
if note:
|
||||
index = self.notes.index(note[0])
|
||||
self.notes.pop(index)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Removes all notes to the stub EdxNotes service.
|
||||
"""
|
||||
self.notes = list()
|
||||
|
||||
def filter_by_id(self, data, note_id):
|
||||
"""
|
||||
Filters provided `data(list)` by the `note_id(str)`.
|
||||
"""
|
||||
return self.filter_by(data, "id", note_id)
|
||||
|
||||
def filter_by_user(self, data, user):
|
||||
"""
|
||||
Filters provided `data(list)` by the `user(str)`.
|
||||
"""
|
||||
return self.filter_by(data, "user", user)
|
||||
|
||||
def filter_by_usage_id(self, data, usage_id):
|
||||
"""
|
||||
Filters provided `data(list)` by the `usage_id(str)`.
|
||||
"""
|
||||
return self.filter_by(data, "usage_id", usage_id)
|
||||
|
||||
def filter_by_course_id(self, data, course_id):
|
||||
"""
|
||||
Filters provided `data(list)` by the `course_id(str)`.
|
||||
"""
|
||||
return self.filter_by(data, "course_id", course_id)
|
||||
|
||||
def filter_by(self, data, field_name, value):
|
||||
"""
|
||||
Filters provided `data(list)` by the `field_name(str)` with `value`.
|
||||
"""
|
||||
return [note for note in data if note.get(field_name) == value]
|
||||
|
||||
def search(self, data, query):
|
||||
"""
|
||||
Search the `query(str)` text in the provided `data(list)`.
|
||||
"""
|
||||
return [note for note in data if unicode(query).strip() in note.get("text", "").split()]
|
||||
@@ -189,7 +189,9 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object):
|
||||
)
|
||||
|
||||
if headers is None:
|
||||
headers = dict()
|
||||
headers = {
|
||||
'Access-Control-Allow-Origin': "*",
|
||||
}
|
||||
|
||||
BaseHTTPRequestHandler.send_response(self, status_code)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .youtube import StubYouTubeService
|
||||
from .ora import StubOraService
|
||||
from .lti import StubLtiService
|
||||
from .video_source import VideoSourceHttpService
|
||||
from .edxnotes import StubEdxNotesService
|
||||
|
||||
|
||||
USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM [CONFIG_KEY=CONFIG_VAL, ...]"
|
||||
@@ -21,6 +22,7 @@ SERVICES = {
|
||||
'comments': StubCommentsService,
|
||||
'lti': StubLtiService,
|
||||
'video': VideoSourceHttpService,
|
||||
'edxnotes': StubEdxNotesService,
|
||||
}
|
||||
|
||||
# Log to stdout, including debug messages
|
||||
|
||||
189
common/djangoapps/terrain/stubs/tests/test_edxnotes.py
Normal file
189
common/djangoapps/terrain/stubs/tests/test_edxnotes.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Unit tests for stub EdxNotes implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
import requests
|
||||
from uuid import uuid4
|
||||
from ..edxnotes import StubEdxNotesService
|
||||
|
||||
|
||||
class StubEdxNotesServiceTest(unittest.TestCase):
|
||||
"""
|
||||
Test cases for the stub EdxNotes service.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Start the stub server.
|
||||
"""
|
||||
self.server = StubEdxNotesService()
|
||||
dummy_notes = self._get_dummy_notes(count=2)
|
||||
self.server.add_notes(dummy_notes)
|
||||
self.addCleanup(self.server.shutdown)
|
||||
|
||||
def _get_dummy_notes(self, count=1):
|
||||
"""
|
||||
Returns a list of dummy notes.
|
||||
"""
|
||||
return [self._get_dummy_note() for i in xrange(count)] # pylint: disable=unused-variable
|
||||
|
||||
def _get_dummy_note(self):
|
||||
"""
|
||||
Returns a single dummy note.
|
||||
"""
|
||||
nid = uuid4().hex
|
||||
return {
|
||||
"id": nid,
|
||||
"created": "2014-10-31T10:05:00.000000",
|
||||
"updated": "2014-10-31T10:50:00.101010",
|
||||
"user": "dummy-user-id",
|
||||
"usage_id": "dummy-usage-id",
|
||||
"course_id": "dummy-course-id",
|
||||
"text": "dummy note text " + nid,
|
||||
"quote": "dummy note quote",
|
||||
"ranges": [
|
||||
{
|
||||
"start": "/p[1]",
|
||||
"end": "/p[1]",
|
||||
"startOffset": 0,
|
||||
"endOffset": 10,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def test_note_create(self):
|
||||
dummy_note = {
|
||||
"user": "dummy-user-id",
|
||||
"usage_id": "dummy-usage-id",
|
||||
"course_id": "dummy-course-id",
|
||||
"text": "dummy note text",
|
||||
"quote": "dummy note quote",
|
||||
"ranges": [
|
||||
{
|
||||
"start": "/p[1]",
|
||||
"end": "/p[1]",
|
||||
"startOffset": 0,
|
||||
"endOffset": 10,
|
||||
}
|
||||
],
|
||||
}
|
||||
response = requests.post(self._get_url("api/v1/annotations"), data=json.dumps(dummy_note))
|
||||
self.assertTrue(response.ok)
|
||||
response_content = response.json()
|
||||
self.assertIn("id", response_content)
|
||||
self.assertIn("created", response_content)
|
||||
self.assertIn("updated", response_content)
|
||||
self.assertIn("annotator_schema_version", response_content)
|
||||
self.assertDictContainsSubset(dummy_note, response_content)
|
||||
|
||||
def test_note_read(self):
|
||||
notes = self._get_notes()
|
||||
for note in notes:
|
||||
response = requests.get(self._get_url("api/v1/annotations/" + note["id"]))
|
||||
self.assertTrue(response.ok)
|
||||
self.assertDictEqual(note, response.json())
|
||||
|
||||
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_note_update(self):
|
||||
notes = self._get_notes()
|
||||
for note in notes:
|
||||
response = requests.get(self._get_url("api/v1/annotations/" + note["id"]))
|
||||
self.assertTrue(response.ok)
|
||||
self.assertDictEqual(note, response.json())
|
||||
|
||||
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_search(self):
|
||||
response = requests.get(self._get_url("api/v1/search"), params={
|
||||
"user": "dummy-user-id",
|
||||
"usage_id": "dummy-usage-id",
|
||||
"course_id": "dummy-course-id",
|
||||
})
|
||||
notes = self._get_notes()
|
||||
self.assertTrue(response.ok)
|
||||
self.assertDictEqual({"total": 2, "rows": notes}, response.json())
|
||||
|
||||
response = requests.get(self._get_url("api/v1/search"))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_delete(self):
|
||||
notes = self._get_notes()
|
||||
response = requests.delete(self._get_url("api/v1/annotations/does_not_exist"))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
for note in notes:
|
||||
response = requests.delete(self._get_url("api/v1/annotations/" + note["id"]))
|
||||
self.assertEqual(response.status_code, 204)
|
||||
remaining_notes = self.server.get_notes()
|
||||
self.assertNotIn(note["id"], [note["id"] for note in remaining_notes])
|
||||
|
||||
self.assertEqual(len(remaining_notes), 0)
|
||||
|
||||
def test_update(self):
|
||||
note = self._get_notes()[0]
|
||||
response = requests.put(self._get_url("api/v1/annotations/" + note["id"]), data=json.dumps({
|
||||
"text": "new test text"
|
||||
}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
updated_note = self._get_notes()[0]
|
||||
self.assertEqual("new test text", updated_note["text"])
|
||||
self.assertEqual(note["id"], updated_note["id"])
|
||||
self.assertItemsEqual(note, updated_note)
|
||||
|
||||
response = requests.get(self._get_url("api/v1/annotations/does_not_exist"))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_notes_collection(self):
|
||||
response = requests.get(self._get_url("api/v1/annotations"), params={"user": "dummy-user-id"})
|
||||
self.assertTrue(response.ok)
|
||||
self.assertEqual(len(response.json()), 2)
|
||||
|
||||
response = requests.get(self._get_url("api/v1/annotations"))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_cleanup(self):
|
||||
response = requests.put(self._get_url("cleanup"))
|
||||
self.assertTrue(response.ok)
|
||||
self.assertEqual(len(self.server.get_notes()), 0)
|
||||
|
||||
def test_create_notes(self):
|
||||
dummy_notes = self._get_dummy_notes(count=2)
|
||||
response = requests.post(self._get_url("create_notes"), data=json.dumps(dummy_notes))
|
||||
self.assertTrue(response.ok)
|
||||
self.assertEqual(len(self._get_notes()), 4)
|
||||
|
||||
response = requests.post(self._get_url("create_notes"))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_headers(self):
|
||||
note = self._get_notes()[0]
|
||||
response = requests.get(self._get_url("api/v1/annotations/" + note["id"]))
|
||||
self.assertTrue(response.ok)
|
||||
self.assertEqual(response.headers.get("access-control-allow-origin"), "*")
|
||||
|
||||
response = requests.options(self._get_url("api/v1/annotations/"))
|
||||
self.assertTrue(response.ok)
|
||||
self.assertEqual(response.headers.get("access-control-allow-origin"), "*")
|
||||
self.assertEqual(response.headers.get("access-control-allow-methods"), "GET, POST, PUT, DELETE, OPTIONS")
|
||||
self.assertIn("X-CSRFToken", response.headers.get("access-control-allow-headers"))
|
||||
|
||||
def _get_notes(self):
|
||||
"""
|
||||
Return a list of notes from the stub EdxNotes service.
|
||||
"""
|
||||
notes = self.server.get_notes()
|
||||
self.assertGreater(len(notes), 0, "Notes are empty.")
|
||||
return notes
|
||||
|
||||
def _get_url(self, path):
|
||||
"""
|
||||
Construt a URL to the stub EdxNotes service.
|
||||
"""
|
||||
return "http://127.0.0.1:{port}/{path}/".format(
|
||||
port=self.server.port, path=path
|
||||
)
|
||||
Reference in New Issue
Block a user