Merge pull request #6321 from edx/feature/edxnotes
TNL-213: Student Notes
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
|
||||
)
|
||||
15
common/lib/xmodule/xmodule/edxnotes_utils.py
Normal file
15
common/lib/xmodule/xmodule/edxnotes_utils.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Utilities related to edXNotes.
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
def edxnotes(cls):
|
||||
"""
|
||||
Conditional decorator that loads edxnotes only when they exist.
|
||||
"""
|
||||
if "edxnotes" in sys.modules:
|
||||
from edxnotes.decorators import edxnotes as notes # pylint: disable=import-error
|
||||
return notes(cls)
|
||||
else:
|
||||
return cls
|
||||
@@ -16,6 +16,8 @@ from xmodule.xml_module import XmlDescriptor, name_to_pathname
|
||||
import textwrap
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xblock.core import XBlock
|
||||
from xmodule.edxnotes_utils import edxnotes
|
||||
|
||||
|
||||
log = logging.getLogger("edx.courseware")
|
||||
|
||||
@@ -51,7 +53,10 @@ class HtmlFields(object):
|
||||
)
|
||||
|
||||
|
||||
class HtmlModule(HtmlFields, XModule):
|
||||
class HtmlModuleMixin(HtmlFields, XModule):
|
||||
"""
|
||||
Attributes and methods used by HtmlModules internally.
|
||||
"""
|
||||
js = {
|
||||
'coffee': [
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
@@ -72,6 +77,14 @@ class HtmlModule(HtmlFields, XModule):
|
||||
return self.data
|
||||
|
||||
|
||||
@edxnotes
|
||||
class HtmlModule(HtmlModuleMixin):
|
||||
"""
|
||||
Module for putting raw html in a course
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for putting raw html in a course
|
||||
@@ -255,7 +268,7 @@ class AboutFields(object):
|
||||
|
||||
|
||||
@XBlock.tag("detached")
|
||||
class AboutModule(AboutFields, HtmlModule):
|
||||
class AboutModule(AboutFields, HtmlModuleMixin):
|
||||
"""
|
||||
Overriding defaults but otherwise treated as HtmlModule.
|
||||
"""
|
||||
@@ -292,7 +305,7 @@ class StaticTabFields(object):
|
||||
|
||||
|
||||
@XBlock.tag("detached")
|
||||
class StaticTabModule(StaticTabFields, HtmlModule):
|
||||
class StaticTabModule(StaticTabFields, HtmlModuleMixin):
|
||||
"""
|
||||
Supports the field overrides
|
||||
"""
|
||||
@@ -326,7 +339,7 @@ class CourseInfoFields(object):
|
||||
|
||||
|
||||
@XBlock.tag("detached")
|
||||
class CourseInfoModule(CourseInfoFields, HtmlModule):
|
||||
class CourseInfoModule(CourseInfoFields, HtmlModuleMixin):
|
||||
"""
|
||||
Just to support xblock field overrides
|
||||
"""
|
||||
|
||||
@@ -35,7 +35,7 @@ src_paths:
|
||||
lib_paths:
|
||||
- common_static/js/test/i18n.js
|
||||
- common_static/coffee/src/ajax_prefix.js
|
||||
- common_static/coffee/src/logger.js
|
||||
- common_static/js/src/logger.js
|
||||
- common_static/js/vendor/jasmine-jquery.js
|
||||
- common_static/js/vendor/jasmine-imagediff.js
|
||||
- common_static/js/vendor/require.js
|
||||
|
||||
@@ -34,7 +34,7 @@ describe 'Crowdsourced hinter', ->
|
||||
response =
|
||||
success: 'incorrect'
|
||||
contents: 'mock grader response'
|
||||
settings.success(response)
|
||||
settings.success(response) if settings
|
||||
)
|
||||
@problem.answers = 'test answer'
|
||||
@problem.check_fd()
|
||||
|
||||
@@ -172,6 +172,19 @@ class InheritanceMixin(XBlockMixin):
|
||||
scope=Scope.settings,
|
||||
default=default_reset_button
|
||||
)
|
||||
edxnotes = Boolean(
|
||||
display_name=_("Enable Student Notes"),
|
||||
help=_("Enter true or false. If true, students can use the Student Notes feature."),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
edxnotes_visibility = Boolean(
|
||||
display_name="Student Notes Visibility",
|
||||
help=_("Indicates whether Student Notes are visible in the course. "
|
||||
"Students can also show or hide their notes in the courseware."),
|
||||
default=True,
|
||||
scope=Scope.user_info
|
||||
)
|
||||
|
||||
|
||||
def compute_inherited_metadata(descriptor):
|
||||
|
||||
@@ -69,6 +69,7 @@ class CourseTab(object): # pylint: disable=incomplete-protocol
|
||||
settings: The configuration settings, including values for:
|
||||
WIKI_ENABLED
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE']
|
||||
FEATURES['ENABLE_EDXNOTES']
|
||||
FEATURES['ENABLE_STUDENT_NOTES']
|
||||
FEATURES['ENABLE_TEXTBOOK']
|
||||
|
||||
@@ -195,6 +196,7 @@ class CourseTab(object): # pylint: disable=incomplete-protocol
|
||||
'staff_grading': StaffGradingTab,
|
||||
'open_ended': OpenEndedGradingTab,
|
||||
'notes': NotesTab,
|
||||
'edxnotes': EdxNotesTab,
|
||||
'syllabus': SyllabusTab,
|
||||
'instructor': InstructorTab, # not persisted
|
||||
}
|
||||
@@ -694,6 +696,27 @@ class NotesTab(AuthenticatedCourseTab):
|
||||
return super(NotesTab, cls).validate(tab_dict, raise_error) and need_name(tab_dict, raise_error)
|
||||
|
||||
|
||||
class EdxNotesTab(AuthenticatedCourseTab):
|
||||
"""
|
||||
A tab for the course student notes.
|
||||
"""
|
||||
type = 'edxnotes'
|
||||
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
|
||||
return settings.FEATURES.get('ENABLE_EDXNOTES')
|
||||
|
||||
def __init__(self, tab_dict=None):
|
||||
super(EdxNotesTab, self).__init__(
|
||||
name=tab_dict['name'] if tab_dict else _('Notes'),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func(self.type),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, tab_dict, raise_error=True):
|
||||
return super(EdxNotesTab, cls).validate(tab_dict, raise_error) and need_name(tab_dict, raise_error)
|
||||
|
||||
|
||||
class InstructorTab(StaffTab):
|
||||
"""
|
||||
A tab for the course instructors.
|
||||
@@ -854,13 +877,13 @@ class CourseTabList(List):
|
||||
|
||||
# the following tabs should appear only once
|
||||
for tab_type in [
|
||||
CoursewareTab.type,
|
||||
CourseInfoTab.type,
|
||||
NotesTab.type,
|
||||
TextbookTabs.type,
|
||||
PDFTextbookTabs.type,
|
||||
HtmlTextbookTabs.type,
|
||||
]:
|
||||
CoursewareTab.type,
|
||||
CourseInfoTab.type,
|
||||
NotesTab.type,
|
||||
TextbookTabs.type,
|
||||
PDFTextbookTabs.type,
|
||||
HtmlTextbookTabs.type,
|
||||
EdxNotesTab.type]:
|
||||
cls._validate_num_tabs_of_type(tabs, tab_type, 1)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -412,6 +412,40 @@ class InstructorTestCase(TabTestCase):
|
||||
self.check_can_display_results(tab, for_staff_only=True)
|
||||
|
||||
|
||||
class EdxNotesTestCase(TabTestCase):
|
||||
"""
|
||||
Test cases for Notes Tab.
|
||||
"""
|
||||
|
||||
def check_edxnotes_tab(self):
|
||||
"""
|
||||
Helper function for verifying the edxnotes tab.
|
||||
"""
|
||||
return self.check_tab(
|
||||
tab_class=tabs.EdxNotesTab,
|
||||
dict_tab={'type': tabs.EdxNotesTab.type, 'name': 'same'},
|
||||
expected_link=self.reverse('edxnotes', args=[self.course.id.to_deprecated_string()]),
|
||||
expected_tab_id=tabs.EdxNotesTab.type,
|
||||
invalid_dict_tab=self.fake_dict_tab,
|
||||
)
|
||||
|
||||
def test_edxnotes_tabs_enabled(self):
|
||||
"""
|
||||
Tests that edxnotes tab is shown when feature is enabled.
|
||||
"""
|
||||
self.settings.FEATURES['ENABLE_EDXNOTES'] = True
|
||||
tab = self.check_edxnotes_tab()
|
||||
self.check_can_display_results(tab, for_authenticated_users_only=True)
|
||||
|
||||
def test_edxnotes_tabs_disabled(self):
|
||||
"""
|
||||
Tests that edxnotes tab is not shown when feature is disabled.
|
||||
"""
|
||||
self.settings.FEATURES['ENABLE_EDXNOTES'] = False
|
||||
tab = self.check_edxnotes_tab()
|
||||
self.check_can_display_results(tab, expected_value=False)
|
||||
|
||||
|
||||
class KeyCheckerTestCase(unittest.TestCase):
|
||||
"""Test cases for KeyChecker class"""
|
||||
|
||||
@@ -473,6 +507,7 @@ class TabListTestCase(TabTestCase):
|
||||
tabs.TextbookTabs.type,
|
||||
tabs.PDFTextbookTabs.type,
|
||||
tabs.HtmlTextbookTabs.type,
|
||||
tabs.EdxNotesTab.type,
|
||||
]
|
||||
|
||||
for unique_tab_type in unique_tab_types:
|
||||
@@ -505,6 +540,7 @@ class TabListTestCase(TabTestCase):
|
||||
{'type': tabs.OpenEndedGradingTab.type},
|
||||
{'type': tabs.NotesTab.type, 'name': 'fake_name'},
|
||||
{'type': tabs.SyllabusTab.type},
|
||||
{'type': tabs.EdxNotesTab.type, 'name': 'fake_name'},
|
||||
],
|
||||
# with external discussion
|
||||
[
|
||||
@@ -565,6 +601,7 @@ class CourseTabListTestCase(TabListTestCase):
|
||||
self.settings.FEATURES['ENABLE_TEXTBOOK'] = True
|
||||
self.settings.FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
|
||||
self.settings.FEATURES['ENABLE_STUDENT_NOTES'] = True
|
||||
self.settings.FEATURES['ENABLE_EDXNOTES'] = True
|
||||
self.course.hide_progress_tab = False
|
||||
|
||||
# create 1 book per textbook type
|
||||
|
||||
@@ -546,7 +546,7 @@ browser and pasting the output. When that file changes, this one should be rege
|
||||
<li class="actions-item">
|
||||
<a href="javascript:void(0)" class="action-list-item action-edit" role="button">
|
||||
<span class="action-label">Edit</span>
|
||||
<span class="action-icon"><i class="icon fa fa-pencil"></i></span>
|
||||
<span class="action-icon"><i class="icon fa fa-pencil-square-o"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
</script>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
describe 'Logger', ->
|
||||
it 'expose window.log_event', ->
|
||||
expect(window.log_event).toBe Logger.log
|
||||
|
||||
describe 'log', ->
|
||||
it 'send a request to log event', ->
|
||||
spyOn jQuery, 'postWithPrefix'
|
||||
Logger.log 'example', 'data'
|
||||
expect(jQuery.postWithPrefix).toHaveBeenCalledWith '/event',
|
||||
event_type: 'example'
|
||||
event: '"data"'
|
||||
page: window.location.href
|
||||
|
||||
# Broken with commit 9f75e64? Skipping for now.
|
||||
xdescribe 'bind', ->
|
||||
beforeEach ->
|
||||
Logger.bind()
|
||||
Courseware.prefix = '/6002x'
|
||||
|
||||
afterEach ->
|
||||
window.onunload = null
|
||||
|
||||
it 'bind the onunload event', ->
|
||||
expect(window.onunload).toEqual jasmine.any(Function)
|
||||
|
||||
it 'send a request to log event', ->
|
||||
spyOn($, 'ajax')
|
||||
window.onunload()
|
||||
expect($.ajax).toHaveBeenCalledWith
|
||||
url: "#{Courseware.prefix}/event",
|
||||
data:
|
||||
event_type: 'page_close'
|
||||
event: ''
|
||||
page: window.location.href
|
||||
async: false
|
||||
@@ -1,48 +0,0 @@
|
||||
class @Logger
|
||||
|
||||
# listeners[event_type][element] -> list of callbacks
|
||||
listeners = {}
|
||||
@log: (event_type, data, element = null) ->
|
||||
# Check to see if we're listening for the event type.
|
||||
if event_type of listeners
|
||||
# Cool. Do the elements also match?
|
||||
# null element in the listener dictionary means any element will do.
|
||||
# null element in the @log call means we don't know the element name.
|
||||
if null of listeners[event_type]
|
||||
# Make the callbacks.
|
||||
for callback in listeners[event_type][null]
|
||||
callback(event_type, data, element)
|
||||
else if element of listeners[event_type]
|
||||
for callback in listeners[event_type][element]
|
||||
callback(event_type, data, element)
|
||||
|
||||
# Regardless of whether any callbacks were made, log this event.
|
||||
$.postWithPrefix '/event',
|
||||
event_type: event_type
|
||||
event: JSON.stringify(data)
|
||||
page: window.location.href
|
||||
|
||||
@listen: (event_type, element, callback) ->
|
||||
# Add a listener. If you want any element to trigger this listener,
|
||||
# do element = null
|
||||
if event_type not of listeners
|
||||
listeners[event_type] = {}
|
||||
if element not of listeners[event_type]
|
||||
listeners[event_type][element] = [callback]
|
||||
else
|
||||
listeners[event_type][element].push callback
|
||||
|
||||
@bind: ->
|
||||
window.onunload = ->
|
||||
$.ajaxWithPrefix
|
||||
url: "/event"
|
||||
data:
|
||||
event_type: 'page_close'
|
||||
event: ''
|
||||
page: window.location.href
|
||||
async: false
|
||||
|
||||
|
||||
# log_event exists for compatibility reasons
|
||||
# and will soon be deprecated.
|
||||
@log_event = Logger.log
|
||||
2
common/static/css/vendor/edxnotes/annotator.min.css
vendored
Normal file
2
common/static/css/vendor/edxnotes/annotator.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
108
common/static/js/spec/logger_spec.js
Normal file
108
common/static/js/spec/logger_spec.js
Normal file
@@ -0,0 +1,108 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
describe('Logger', function() {
|
||||
it('expose window.log_event', function() {
|
||||
expect(window.log_event).toBe(Logger.log);
|
||||
});
|
||||
|
||||
describe('log', function() {
|
||||
it('can send a request to log event', function() {
|
||||
spyOn(jQuery, 'ajaxWithPrefix');
|
||||
Logger.log('example', 'data');
|
||||
expect(jQuery.ajaxWithPrefix).toHaveBeenCalledWith({
|
||||
url: '/event',
|
||||
type: 'POST',
|
||||
data: {
|
||||
event_type: 'example',
|
||||
event: '"data"',
|
||||
page: window.location.href
|
||||
},
|
||||
async: true
|
||||
});
|
||||
});
|
||||
|
||||
it('can send a request with custom options to log event', function() {
|
||||
spyOn(jQuery, 'ajaxWithPrefix');
|
||||
Logger.log('example', 'data', null, {type: 'GET', async: false});
|
||||
expect(jQuery.ajaxWithPrefix).toHaveBeenCalledWith({
|
||||
url: '/event',
|
||||
type: 'GET',
|
||||
data: {
|
||||
event_type: 'example',
|
||||
event: '"data"',
|
||||
page: window.location.href
|
||||
},
|
||||
async: false
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listen', function() {
|
||||
beforeEach(function () {
|
||||
spyOn(jQuery, 'ajaxWithPrefix');
|
||||
this.callbacks = _.map(_.range(4), function () {
|
||||
return jasmine.createSpy();
|
||||
});
|
||||
Logger.listen('example', null, this.callbacks[0]);
|
||||
Logger.listen('example', null, this.callbacks[1]);
|
||||
Logger.listen('example', 'element', this.callbacks[2]);
|
||||
Logger.listen('new_event', null, this.callbacks[3]);
|
||||
});
|
||||
|
||||
it('can listen events when the element name is unknown', function() {
|
||||
Logger.log('example', 'data');
|
||||
expect(this.callbacks[0]).toHaveBeenCalledWith('example', 'data', null);
|
||||
expect(this.callbacks[1]).toHaveBeenCalledWith('example', 'data', null);
|
||||
expect(this.callbacks[2]).not.toHaveBeenCalled();
|
||||
expect(this.callbacks[3]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can listen events when the element name is known', function() {
|
||||
Logger.log('example', 'data', 'element');
|
||||
expect(this.callbacks[0]).not.toHaveBeenCalled();
|
||||
expect(this.callbacks[1]).not.toHaveBeenCalled();
|
||||
expect(this.callbacks[2]).toHaveBeenCalledWith('example', 'data', 'element');
|
||||
expect(this.callbacks[3]).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bind', function() {
|
||||
beforeEach(function() {
|
||||
this.initialPostWithPrefix = jQuery.postWithPrefix;
|
||||
this.initialGetWithPrefix = jQuery.getWithPrefix;
|
||||
this.initialAjaxWithPrefix = jQuery.ajaxWithPrefix;
|
||||
this.prefix = '/6002x';
|
||||
AjaxPrefix.addAjaxPrefix($, _.bind(function () {
|
||||
return this.prefix;
|
||||
}, this));
|
||||
Logger.bind();
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
jQuery.postWithPrefix = this.initialPostWithPrefix;
|
||||
jQuery.getWithPrefix = this.initialGetWithPrefix;
|
||||
jQuery.ajaxWithPrefix = this.initialAjaxWithPrefix;
|
||||
window.onunload = null;
|
||||
});
|
||||
|
||||
it('can bind the onunload event', function() {
|
||||
expect(window.onunload).toEqual(jasmine.any(Function));
|
||||
});
|
||||
|
||||
it('can send a request to log event', function() {
|
||||
spyOn(jQuery, 'ajax');
|
||||
window.onunload();
|
||||
expect(jQuery.ajax).toHaveBeenCalledWith({
|
||||
url: this.prefix + '/event',
|
||||
type: 'GET',
|
||||
data: {
|
||||
event_type: 'page_close',
|
||||
event: '',
|
||||
page: window.location.href
|
||||
},
|
||||
async: false
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}).call(this);
|
||||
82
common/static/js/src/logger.js
Normal file
82
common/static/js/src/logger.js
Normal file
@@ -0,0 +1,82 @@
|
||||
;(function() {
|
||||
'use strict';
|
||||
var Logger = (function() {
|
||||
// listeners[event_type][element] -> list of callbacks
|
||||
var listeners = {},
|
||||
sendRequest, has;
|
||||
|
||||
sendRequest = function(data, options) {
|
||||
var request = $.ajaxWithPrefix ? $.ajaxWithPrefix : $.ajax;
|
||||
|
||||
options = $.extend(true, {
|
||||
'url': '/event',
|
||||
'type': 'POST',
|
||||
'data': data,
|
||||
'async': true
|
||||
}, options);
|
||||
return request(options);
|
||||
};
|
||||
|
||||
has = function(object, propertyName) {
|
||||
return {}.hasOwnProperty.call(object, propertyName);
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Emits an event.
|
||||
*/
|
||||
log: function(eventType, data, element, requestOptions) {
|
||||
var callbacks;
|
||||
|
||||
if (!element) {
|
||||
// null element in the listener dictionary means any element will do.
|
||||
// null element in the Logger.log call means we don't know the element name.
|
||||
element = null;
|
||||
}
|
||||
// Check to see if we're listening for the event type.
|
||||
if (has(listeners, eventType)) {
|
||||
if (has(listeners[eventType], element)) {
|
||||
// Make the callbacks.
|
||||
callbacks = listeners[eventType][element];
|
||||
$.each(callbacks, function(index, callback) {
|
||||
callback(eventType, data, element);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Regardless of whether any callbacks were made, log this event.
|
||||
return sendRequest({
|
||||
'event_type': eventType,
|
||||
'event': JSON.stringify(data),
|
||||
'page': window.location.href
|
||||
}, requestOptions);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds a listener. If you want any element to trigger this listener,
|
||||
* do element = null
|
||||
*/
|
||||
listen: function(eventType, element, callback) {
|
||||
listeners[eventType] = listeners[eventType] || {};
|
||||
listeners[eventType][element] = listeners[eventType][element] || [];
|
||||
listeners[eventType][element].push(callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* Binds `page_close` event.
|
||||
*/
|
||||
bind: function() {
|
||||
window.onunload = function() {
|
||||
sendRequest({
|
||||
event_type: 'page_close',
|
||||
event: '',
|
||||
page: window.location.href
|
||||
}, {type: 'GET', async: false});
|
||||
};
|
||||
}
|
||||
};
|
||||
}());
|
||||
|
||||
this.Logger = Logger;
|
||||
// log_event exists for compatibility reasons and will soon be deprecated.
|
||||
this.log_event = Logger.log;
|
||||
}).call(this);
|
||||
26
common/static/js/vendor/edxnotes/annotator-full.min.js
vendored
Normal file
26
common/static/js/vendor/edxnotes/annotator-full.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
17
common/templates/edxnotes_wrapper.html
Normal file
17
common/templates/edxnotes_wrapper.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<%! import json %>
|
||||
<%! from student.models import anonymous_id_for_user %>
|
||||
<%
|
||||
if user:
|
||||
params.update({'user': anonymous_id_for_user(user, None)})
|
||||
%>
|
||||
<div id="edx-notes-wrapper-${uid}" class="edx-notes-wrapper">
|
||||
<div class="edx-notes-wrapper-content">${content}</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
(function (require) {
|
||||
require(['js/edxnotes/views/visibility_decorator'], function(EdxnotesVisibilityDecorator) {
|
||||
var element = document.getElementById('edx-notes-wrapper-${uid}');
|
||||
EdxnotesVisibilityDecorator.factory(element, ${json.dumps(params)}, ${edxnotes_visibility});
|
||||
});
|
||||
}).call(this, require || RequireJS.require);
|
||||
</script>
|
||||
@@ -14,3 +14,6 @@ ORA_STUB_URL = os.environ.get('ora_url', 'http://localhost:8041')
|
||||
|
||||
# Get the URL of the comments service stub used in the test
|
||||
COMMENTS_STUB_URL = os.environ.get('comments_url', 'http://localhost:4567')
|
||||
|
||||
# Get the URL of the EdxNotes service stub used in the test
|
||||
EDXNOTES_STUB_URL = os.environ.get('edxnotes_url', 'http://localhost:8042')
|
||||
|
||||
76
common/test/acceptance/fixtures/edxnotes.py
Normal file
76
common/test/acceptance/fixtures/edxnotes.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Tools for creating edxnotes content fixture data.
|
||||
"""
|
||||
|
||||
import json
|
||||
import factory
|
||||
import requests
|
||||
|
||||
from . import EDXNOTES_STUB_URL
|
||||
|
||||
|
||||
class Range(factory.Factory):
|
||||
FACTORY_FOR = dict
|
||||
start = "/div[1]/p[1]"
|
||||
end = "/div[1]/p[1]"
|
||||
startOffset = 0
|
||||
endOffset = 8
|
||||
|
||||
|
||||
class Note(factory.Factory):
|
||||
FACTORY_FOR = dict
|
||||
user = "dummy-user"
|
||||
usage_id = "dummy-usage-id"
|
||||
course_id = "dummy-course-id"
|
||||
text = "dummy note text"
|
||||
quote = "dummy note quote"
|
||||
ranges = [Range()]
|
||||
|
||||
|
||||
class EdxNotesFixtureError(Exception):
|
||||
"""
|
||||
Error occurred while installing a edxnote fixture.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EdxNotesFixture(object):
|
||||
notes = []
|
||||
|
||||
def create_notes(self, notes_list):
|
||||
self.notes = notes_list
|
||||
return self
|
||||
|
||||
def install(self):
|
||||
"""
|
||||
Push the data to the stub EdxNotes service.
|
||||
"""
|
||||
response = requests.post(
|
||||
'{}/create_notes'.format(EDXNOTES_STUB_URL),
|
||||
data=json.dumps(self.notes)
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise EdxNotesFixtureError(
|
||||
"Could not create notes {0}. Status was {1}".format(
|
||||
json.dumps(self.notes), response.status_code
|
||||
)
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the stub EdxNotes service.
|
||||
"""
|
||||
self.notes = []
|
||||
response = requests.put('{}/cleanup'.format(EDXNOTES_STUB_URL))
|
||||
|
||||
if not response.ok:
|
||||
raise EdxNotesFixtureError(
|
||||
"Could not cleanup EdxNotes service {0}. Status was {1}".format(
|
||||
json.dumps(self.notes), response.status_code
|
||||
)
|
||||
)
|
||||
|
||||
return self
|
||||
@@ -61,7 +61,14 @@ class CoursewarePage(CoursePage):
|
||||
(default is 0)
|
||||
|
||||
"""
|
||||
return self.q(css=self.xblock_component_selector).attrs('innerHTML')[index].strip()
|
||||
# When Student Notes feature is enabled, it looks for the content inside
|
||||
# `.edx-notes-wrapper-content` element (Otherwise, you will get an
|
||||
# additional html related to Student Notes).
|
||||
element = self.q(css='{} .edx-notes-wrapper-content'.format(self.xblock_component_selector))
|
||||
if element.first:
|
||||
return element.attrs('innerHTML')[index].strip()
|
||||
else:
|
||||
return self.q(css=self.xblock_component_selector).attrs('innerHTML')[index].strip()
|
||||
|
||||
def tooltips_displayed(self):
|
||||
"""
|
||||
|
||||
540
common/test/acceptance/pages/lms/edxnotes.py
Normal file
540
common/test/acceptance/pages/lms/edxnotes.py
Normal file
@@ -0,0 +1,540 @@
|
||||
from bok_choy.page_object import PageObject, PageLoadError, unguarded
|
||||
from bok_choy.promise import BrokenPromise
|
||||
from .course_page import CoursePage
|
||||
from ...tests.helpers import disable_animations
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
|
||||
|
||||
class NoteChild(PageObject):
|
||||
url = None
|
||||
BODY_SELECTOR = None
|
||||
|
||||
def __init__(self, browser, item_id):
|
||||
super(NoteChild, self).__init__(browser)
|
||||
self.item_id = item_id
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css="{}#{}".format(self.BODY_SELECTOR, self.item_id)).present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular `NoteChild` context
|
||||
"""
|
||||
return "{}#{} {}".format(
|
||||
self.BODY_SELECTOR,
|
||||
self.item_id,
|
||||
selector,
|
||||
)
|
||||
|
||||
def _get_element_text(self, selector):
|
||||
element = self.q(css=self._bounded_selector(selector)).first
|
||||
if element:
|
||||
return element.text[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class EdxNotesPageGroup(NoteChild):
|
||||
"""
|
||||
Helper class that works with note groups on Note page of the course.
|
||||
"""
|
||||
BODY_SELECTOR = ".note-group"
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self._get_element_text(".course-title")
|
||||
|
||||
@property
|
||||
def subtitles(self):
|
||||
return [section.title for section in self.children]
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
children = self.q(css=self._bounded_selector('.note-section'))
|
||||
return [EdxNotesPageSection(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
|
||||
class EdxNotesPageSection(NoteChild):
|
||||
"""
|
||||
Helper class that works with note sections on Note page of the course.
|
||||
"""
|
||||
BODY_SELECTOR = ".note-section"
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self._get_element_text(".course-subtitle")
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
children = self.q(css=self._bounded_selector('.note'))
|
||||
return [EdxNotesPageItem(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
return [section.text for section in self.children]
|
||||
|
||||
|
||||
class EdxNotesPageItem(NoteChild):
|
||||
"""
|
||||
Helper class that works with note items on Note page of the course.
|
||||
"""
|
||||
BODY_SELECTOR = ".note"
|
||||
UNIT_LINK_SELECTOR = "a.reference-unit-link"
|
||||
|
||||
def go_to_unit(self, unit_page=None):
|
||||
self.q(css=self._bounded_selector(self.UNIT_LINK_SELECTOR)).click()
|
||||
if unit_page is not None:
|
||||
unit_page.wait_for_page()
|
||||
|
||||
@property
|
||||
def unit_name(self):
|
||||
return self._get_element_text(self.UNIT_LINK_SELECTOR)
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self._get_element_text(".note-comment-p")
|
||||
|
||||
@property
|
||||
def quote(self):
|
||||
return self._get_element_text(".note-excerpt")
|
||||
|
||||
@property
|
||||
def time_updated(self):
|
||||
return self._get_element_text(".reference-updated-date")
|
||||
|
||||
|
||||
class EdxNotesPageView(PageObject):
|
||||
"""
|
||||
Base class for EdxNotes views: Recent Activity, Location in Course, Search Results.
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = ".tab-panel"
|
||||
TAB_SELECTOR = ".tab"
|
||||
CHILD_SELECTOR = ".note"
|
||||
CHILD_CLASS = EdxNotesPageItem
|
||||
|
||||
@unguarded
|
||||
def visit(self):
|
||||
"""
|
||||
Open the page containing this page object in the browser.
|
||||
|
||||
Raises:
|
||||
PageLoadError: The page did not load successfully.
|
||||
|
||||
Returns:
|
||||
PageObject
|
||||
"""
|
||||
self.q(css=self.TAB_SELECTOR).first.click()
|
||||
try:
|
||||
return self.wait_for_page()
|
||||
except (BrokenPromise):
|
||||
raise PageLoadError("Timed out waiting to load page '{!r}'".format(self))
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return all([
|
||||
self.q(css="{}".format(self.BODY_SELECTOR)).present,
|
||||
self.q(css="{}.is-active".format(self.TAB_SELECTOR)).present,
|
||||
not self.q(css=".ui-loading").visible,
|
||||
])
|
||||
|
||||
@property
|
||||
def is_closable(self):
|
||||
"""
|
||||
Indicates if tab is closable or not.
|
||||
"""
|
||||
return self.q(css="{} .action-close".format(self.TAB_SELECTOR)).present
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Closes the tab.
|
||||
"""
|
||||
self.q(css="{} .action-close".format(self.TAB_SELECTOR)).first.click()
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
"""
|
||||
Returns all notes on the page.
|
||||
"""
|
||||
children = self.q(css=self.CHILD_SELECTOR)
|
||||
return [self.CHILD_CLASS(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
|
||||
class RecentActivityView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Recent Activity view.
|
||||
"""
|
||||
BODY_SELECTOR = "#recent-panel"
|
||||
TAB_SELECTOR = ".tab#view-recent-activity"
|
||||
|
||||
|
||||
class CourseStructureView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Location in Course view.
|
||||
"""
|
||||
BODY_SELECTOR = "#structure-panel"
|
||||
TAB_SELECTOR = ".tab#view-course-structure"
|
||||
CHILD_SELECTOR = ".note-group"
|
||||
CHILD_CLASS = EdxNotesPageGroup
|
||||
|
||||
|
||||
class SearchResultsView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Search Results view.
|
||||
"""
|
||||
BODY_SELECTOR = "#search-results-panel"
|
||||
TAB_SELECTOR = ".tab#view-search-results"
|
||||
|
||||
|
||||
class EdxNotesPage(CoursePage):
|
||||
"""
|
||||
EdxNotes page.
|
||||
"""
|
||||
url_path = "edxnotes/"
|
||||
MAPPING = {
|
||||
"recent": RecentActivityView,
|
||||
"structure": CourseStructureView,
|
||||
"search": SearchResultsView,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(EdxNotesPage, self).__init__(*args, **kwargs)
|
||||
self.current_view = self.MAPPING["recent"](self.browser)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css=".wrapper-student-notes").present
|
||||
|
||||
def switch_to_tab(self, tab_name):
|
||||
"""
|
||||
Switches to the appropriate tab `tab_name(str)`.
|
||||
"""
|
||||
self.current_view = self.MAPPING[tab_name](self.browser)
|
||||
self.current_view.visit()
|
||||
|
||||
def close_tab(self, tab_name):
|
||||
"""
|
||||
Closes the tab `tab_name(str)`.
|
||||
"""
|
||||
self.current_view.close()
|
||||
self.current_view = self.MAPPING["recent"](self.browser)
|
||||
|
||||
def search(self, text):
|
||||
"""
|
||||
Runs search with `text(str)` query.
|
||||
"""
|
||||
self.q(css="#search-notes-form #search-notes-input").first.fill(text)
|
||||
self.q(css='#search-notes-form .search-notes-submit').first.click()
|
||||
# Frontend will automatically switch to Search results tab when search
|
||||
# is running, so the view also needs to be changed.
|
||||
self.current_view = self.MAPPING["search"](self.browser)
|
||||
if text.strip():
|
||||
self.current_view.wait_for_page()
|
||||
|
||||
@property
|
||||
def tabs(self):
|
||||
"""
|
||||
Returns all tabs on the page.
|
||||
"""
|
||||
tabs = self.q(css=".tabs .tab-label")
|
||||
if tabs:
|
||||
return map(lambda x: x.replace("Current tab\n", ""), tabs.text)
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_error_visible(self):
|
||||
"""
|
||||
Indicates whether error message is visible or not.
|
||||
"""
|
||||
return self.q(css=".inline-error").visible
|
||||
|
||||
@property
|
||||
def error_text(self):
|
||||
"""
|
||||
Returns error message.
|
||||
"""
|
||||
element = self.q(css=".inline-error").first
|
||||
if element and self.is_error_visible:
|
||||
return element.text[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
"""
|
||||
Returns all notes on the page.
|
||||
"""
|
||||
children = self.q(css='.note')
|
||||
return [EdxNotesPageItem(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def groups(self):
|
||||
"""
|
||||
Returns all groups on the page.
|
||||
"""
|
||||
children = self.q(css='.note-group')
|
||||
return [EdxNotesPageGroup(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def sections(self):
|
||||
"""
|
||||
Returns all sections on the page.
|
||||
"""
|
||||
children = self.q(css='.note-section')
|
||||
return [EdxNotesPageSection(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def no_content_text(self):
|
||||
"""
|
||||
Returns no content message.
|
||||
"""
|
||||
element = self.q(css=".is-empty").first
|
||||
if element:
|
||||
return element.text[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class EdxNotesUnitPage(CoursePage):
|
||||
"""
|
||||
Page for the Unit with EdxNotes.
|
||||
"""
|
||||
url_path = "courseware/"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css="body.courseware .edx-notes-wrapper").present
|
||||
|
||||
def move_mouse_to(self, selector):
|
||||
"""
|
||||
Moves mouse to the element that matches `selector(str)`.
|
||||
"""
|
||||
body = self.q(css=selector)[0]
|
||||
ActionChains(self.browser).move_to_element(body).release().perform()
|
||||
return self
|
||||
|
||||
def click(self, selector):
|
||||
"""
|
||||
Clicks on the element that matches `selector(str)`.
|
||||
"""
|
||||
self.q(css=selector).first.click()
|
||||
return self
|
||||
|
||||
def toggle_visibility(self):
|
||||
"""
|
||||
Clicks on the "Show notes" checkbox.
|
||||
"""
|
||||
self.q(css=".action-toggle-notes").first.click()
|
||||
return self
|
||||
|
||||
@property
|
||||
def components(self):
|
||||
"""
|
||||
Returns a list of annotatable components.
|
||||
"""
|
||||
components = self.q(css=".edx-notes-wrapper")
|
||||
return [AnnotatableComponent(self.browser, component.get_attribute("id")) for component in components]
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
"""
|
||||
Returns a list of notes for the page.
|
||||
"""
|
||||
notes = []
|
||||
for component in self.components:
|
||||
notes.extend(component.notes)
|
||||
return notes
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
Refreshes the page and returns a list of annotatable components.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
return self.components
|
||||
|
||||
|
||||
class AnnotatableComponent(NoteChild):
|
||||
"""
|
||||
Helper class that works with annotatable components.
|
||||
"""
|
||||
BODY_SELECTOR = ".edx-notes-wrapper"
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
"""
|
||||
Returns a list of notes for the component.
|
||||
"""
|
||||
notes = self.q(css=self._bounded_selector(".annotator-hl"))
|
||||
return [EdxNoteHighlight(self.browser, note, self.item_id) for note in notes]
|
||||
|
||||
def create_note(self, selector=".annotate-id"):
|
||||
"""
|
||||
Create the note by the selector, return a context manager that will
|
||||
show and save the note popup.
|
||||
"""
|
||||
for element in self.q(css=self._bounded_selector(selector)):
|
||||
note = EdxNoteHighlight(self.browser, element, self.item_id)
|
||||
note.select_and_click_adder()
|
||||
yield note
|
||||
note.save()
|
||||
|
||||
def edit_note(self, selector=".annotator-hl"):
|
||||
"""
|
||||
Edit the note by the selector, return a context manager that will
|
||||
show and save the note popup.
|
||||
"""
|
||||
for element in self.q(css=self._bounded_selector(selector)):
|
||||
note = EdxNoteHighlight(self.browser, element, self.item_id)
|
||||
note.show().edit()
|
||||
yield note
|
||||
note.save()
|
||||
|
||||
def remove_note(self, selector=".annotator-hl"):
|
||||
"""
|
||||
Removes the note by the selector.
|
||||
"""
|
||||
for element in self.q(css=self._bounded_selector(selector)):
|
||||
note = EdxNoteHighlight(self.browser, element, self.item_id)
|
||||
note.show().remove()
|
||||
|
||||
|
||||
class EdxNoteHighlight(NoteChild):
|
||||
"""
|
||||
Helper class that works with notes.
|
||||
"""
|
||||
BODY_SELECTOR = ""
|
||||
ADDER_SELECTOR = ".annotator-adder"
|
||||
VIEWER_SELECTOR = ".annotator-viewer"
|
||||
EDITOR_SELECTOR = ".annotator-editor"
|
||||
|
||||
def __init__(self, browser, element, parent_id):
|
||||
super(EdxNoteHighlight, self).__init__(browser, parent_id)
|
||||
self.element = element
|
||||
self.item_id = parent_id
|
||||
disable_animations(self)
|
||||
|
||||
@property
|
||||
def is_visible(self):
|
||||
"""
|
||||
Returns True if the note is visible.
|
||||
"""
|
||||
viewer_is_visible = self.q(css=self._bounded_selector(self.VIEWER_SELECTOR)).visible
|
||||
editor_is_visible = self.q(css=self._bounded_selector(self.EDITOR_SELECTOR)).visible
|
||||
return viewer_is_visible or editor_is_visible
|
||||
|
||||
def wait_for_adder_visibility(self):
|
||||
"""
|
||||
Waiting for visibility of note adder button.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
self._bounded_selector(self.ADDER_SELECTOR), "Adder is visible."
|
||||
)
|
||||
|
||||
def wait_for_viewer_visibility(self):
|
||||
"""
|
||||
Waiting for visibility of note viewer.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
self._bounded_selector(self.VIEWER_SELECTOR), "Note Viewer is visible."
|
||||
)
|
||||
|
||||
def wait_for_editor_visibility(self):
|
||||
"""
|
||||
Waiting for visibility of note editor.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
self._bounded_selector(self.EDITOR_SELECTOR), "Note Editor is visible."
|
||||
)
|
||||
|
||||
def wait_for_notes_invisibility(self, text="Notes are hidden"):
|
||||
"""
|
||||
Waiting for invisibility of all notes.
|
||||
"""
|
||||
selector = self._bounded_selector(".annotator-outer")
|
||||
self.wait_for_element_invisibility(selector, text)
|
||||
|
||||
def select_and_click_adder(self):
|
||||
"""
|
||||
Creates selection for the element and clicks `add note` button.
|
||||
"""
|
||||
ActionChains(self.browser).double_click(self.element).release().perform()
|
||||
self.wait_for_adder_visibility()
|
||||
self.q(css=self._bounded_selector(self.ADDER_SELECTOR)).first.click()
|
||||
self.wait_for_editor_visibility()
|
||||
return self
|
||||
|
||||
def click_on_highlight(self):
|
||||
"""
|
||||
Clicks on the highlighted text.
|
||||
"""
|
||||
ActionChains(self.browser).move_to_element(self.element).click().release().perform()
|
||||
return self
|
||||
|
||||
def click_on_viewer(self):
|
||||
"""
|
||||
Clicks on the note viewer.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(self.VIEWER_SELECTOR)).first.click()
|
||||
return self
|
||||
|
||||
def show(self):
|
||||
"""
|
||||
Hover over highlighted text -> shows note.
|
||||
"""
|
||||
ActionChains(self.browser).move_to_element(self.element).release().perform()
|
||||
self.wait_for_viewer_visibility()
|
||||
return self
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Clicks cancel button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-cancel")).first.click()
|
||||
self.wait_for_notes_invisibility("Note is canceled.")
|
||||
return self
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-save")).first.click()
|
||||
self.wait_for_notes_invisibility("Note is saved.")
|
||||
self.wait_for_ajax()
|
||||
return self
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Clicks delete button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-delete")).first.click()
|
||||
self.wait_for_notes_invisibility("Note is removed.")
|
||||
self.wait_for_ajax()
|
||||
return self
|
||||
|
||||
def edit(self):
|
||||
"""
|
||||
Clicks edit button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-edit")).first.click()
|
||||
self.wait_for_editor_visibility()
|
||||
return self
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
"""
|
||||
Returns text of the note.
|
||||
"""
|
||||
self.show()
|
||||
element = self.q(css=self._bounded_selector(".annotator-annotation > div"))
|
||||
if element:
|
||||
text = element.text[0].strip()
|
||||
else:
|
||||
text = None
|
||||
self.q(css=("body")).first.click()
|
||||
self.wait_for_notes_invisibility()
|
||||
return text
|
||||
|
||||
@text.setter
|
||||
def text(self, value):
|
||||
"""
|
||||
Sets text for the note.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-item textarea")).first.fill(value)
|
||||
@@ -9,6 +9,7 @@ import os
|
||||
from path import path
|
||||
from bok_choy.web_app_test import WebAppTest
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from bok_choy.javascript import js_defined
|
||||
|
||||
|
||||
def skip_if_browser(browser):
|
||||
@@ -90,6 +91,7 @@ def enable_animations(page):
|
||||
enable_css_animations(page)
|
||||
|
||||
|
||||
@js_defined('window.jQuery')
|
||||
def disable_jquery_animations(page):
|
||||
"""
|
||||
Disable jQuery animations.
|
||||
@@ -97,6 +99,7 @@ def disable_jquery_animations(page):
|
||||
page.browser.execute_script("jQuery.fx.off = true;")
|
||||
|
||||
|
||||
@js_defined('window.jQuery')
|
||||
def enable_jquery_animations(page):
|
||||
"""
|
||||
Enable jQuery animations.
|
||||
|
||||
775
common/test/acceptance/tests/lms/test_lms_edxnotes.py
Normal file
775
common/test/acceptance/tests/lms/test_lms_edxnotes.py
Normal file
@@ -0,0 +1,775 @@
|
||||
import os
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
from unittest import skipUnless
|
||||
from ..helpers import UniqueCourseTest
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ...pages.lms.auto_auth import AutoAuthPage
|
||||
from ...pages.lms.course_nav import CourseNavPage
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.edxnotes import EdxNotesUnitPage, EdxNotesPage
|
||||
from ...fixtures.edxnotes import EdxNotesFixture, Note, Range
|
||||
|
||||
|
||||
@skipUnless(os.environ.get("FEATURE_EDXNOTES"), "Requires Student Notes feature to be enabled")
|
||||
class EdxNotesTestMixin(UniqueCourseTest):
|
||||
"""
|
||||
Creates a course with initial data and contains useful helper methods.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Initialize pages and install a course fixture.
|
||||
"""
|
||||
super(EdxNotesTestMixin, self).setUp()
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.course_nav = CourseNavPage(self.browser)
|
||||
self.note_unit_page = EdxNotesUnitPage(self.browser, self.course_id)
|
||||
self.notes_page = EdxNotesPage(self.browser, self.course_id)
|
||||
|
||||
self.username = str(uuid4().hex)[:5]
|
||||
self.email = "{}@email.com".format(self.username)
|
||||
|
||||
self.selector = "annotate-id"
|
||||
self.edxnotes_fixture = EdxNotesFixture()
|
||||
self.course_fixture = CourseFixture(
|
||||
self.course_info["org"], self.course_info["number"],
|
||||
self.course_info["run"], self.course_info["display_name"]
|
||||
)
|
||||
|
||||
self.course_fixture.add_advanced_settings({
|
||||
u"edxnotes": {u"value": True}
|
||||
})
|
||||
|
||||
self.course_fixture.add_children(
|
||||
XBlockFixtureDesc("chapter", "Test Section 1").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection 1").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit 1").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 1",
|
||||
data="""
|
||||
<p><span class="{}">Annotate this text!</span></p>
|
||||
<p>Annotate this text</p>
|
||||
""".format(self.selector)
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 2",
|
||||
data="""<p><span class="{}">Annotate this text!</span></p>""".format(self.selector)
|
||||
),
|
||||
),
|
||||
XBlockFixtureDesc("vertical", "Test Unit 2").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 3",
|
||||
data="""<p><span class="{}">Annotate this text!</span></p>""".format(self.selector)
|
||||
),
|
||||
),
|
||||
),
|
||||
XBlockFixtureDesc("sequential", "Test Subsection 2").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit 3").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 4",
|
||||
data="""
|
||||
<p><span class="{}">Annotate this text!</span></p>
|
||||
""".format(self.selector)
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
XBlockFixtureDesc("chapter", "Test Section 2").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection 3").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit 4").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 5",
|
||||
data="""
|
||||
<p><span class="{}">Annotate this text!</span></p>
|
||||
""".format(self.selector)
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
"html",
|
||||
"Test HTML 6",
|
||||
data="""<p><span class="{}">Annotate this text!</span></p>""".format(self.selector)
|
||||
),
|
||||
),
|
||||
),
|
||||
)).install()
|
||||
|
||||
AutoAuthPage(self.browser, username=self.username, email=self.email, course_id=self.course_id).visit()
|
||||
|
||||
def tearDown(self):
|
||||
self.edxnotes_fixture.cleanup()
|
||||
|
||||
def _add_notes(self):
|
||||
xblocks = self.course_fixture.get_nested_xblocks(category="html")
|
||||
notes_list = []
|
||||
for index, xblock in enumerate(xblocks):
|
||||
notes_list.append(
|
||||
Note(
|
||||
user=self.username,
|
||||
usage_id=xblock.locator,
|
||||
course_id=self.course_fixture._course_key,
|
||||
ranges=[Range(startOffset=index, endOffset=index + 5)]
|
||||
)
|
||||
)
|
||||
|
||||
self.edxnotes_fixture.create_notes(notes_list)
|
||||
self.edxnotes_fixture.install()
|
||||
|
||||
|
||||
class EdxNotesDefaultInteractionsTest(EdxNotesTestMixin):
|
||||
"""
|
||||
Tests for creation, editing, deleting annotations inside annotatable components in LMS.
|
||||
"""
|
||||
def create_notes(self, components, offset=0):
|
||||
self.assertGreater(len(components), 0)
|
||||
index = offset
|
||||
for component in components:
|
||||
for note in component.create_note(".{}".format(self.selector)):
|
||||
note.text = "TEST TEXT {}".format(index)
|
||||
index += 1
|
||||
|
||||
def edit_notes(self, components, offset=0):
|
||||
self.assertGreater(len(components), 0)
|
||||
index = offset
|
||||
for component in components:
|
||||
self.assertGreater(len(component.notes), 0)
|
||||
for note in component.edit_note():
|
||||
note.text = "TEST TEXT {}".format(index)
|
||||
index += 1
|
||||
|
||||
def remove_notes(self, components):
|
||||
self.assertGreater(len(components), 0)
|
||||
for component in components:
|
||||
self.assertGreater(len(component.notes), 0)
|
||||
component.remove_note()
|
||||
|
||||
def assert_notes_are_removed(self, components):
|
||||
for component in components:
|
||||
self.assertEqual(0, len(component.notes))
|
||||
|
||||
def assert_text_in_notes(self, notes):
|
||||
actual = [note.text for note in notes]
|
||||
expected = ["TEST TEXT {}".format(i) for i in xrange(len(notes))]
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_can_create_notes(self):
|
||||
"""
|
||||
Scenario: User can create notes.
|
||||
Given I have a course with 3 annotatable components
|
||||
And I open the unit with 2 annotatable components
|
||||
When I add 2 notes for the first component and 1 note for the second
|
||||
Then I see that notes were correctly created
|
||||
When I change sequential position to "2"
|
||||
And I add note for the annotatable component on the page
|
||||
Then I see that note was correctly created
|
||||
When I refresh the page
|
||||
Then I see that note was correctly stored
|
||||
When I change sequential position to "1"
|
||||
Then I see that notes were correctly stored on the page
|
||||
"""
|
||||
self.note_unit_page.visit()
|
||||
|
||||
components = self.note_unit_page.components
|
||||
self.create_notes(components)
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
components = self.note_unit_page.components
|
||||
self.create_notes(components)
|
||||
|
||||
components = self.note_unit_page.refresh()
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
self.course_nav.go_to_sequential_position(1)
|
||||
components = self.note_unit_page.components
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
def test_can_edit_notes(self):
|
||||
"""
|
||||
Scenario: User can edit notes.
|
||||
Given I have a course with 3 components with notes
|
||||
And I open the unit with 2 annotatable components
|
||||
When I change text in the notes
|
||||
Then I see that notes were correctly changed
|
||||
When I change sequential position to "2"
|
||||
And I change the note on the page
|
||||
Then I see that note was correctly changed
|
||||
When I refresh the page
|
||||
Then I see that edited note was correctly stored
|
||||
When I change sequential position to "1"
|
||||
Then I see that edited notes were correctly stored on the page
|
||||
"""
|
||||
self._add_notes()
|
||||
self.note_unit_page.visit()
|
||||
|
||||
components = self.note_unit_page.components
|
||||
self.edit_notes(components)
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
components = self.note_unit_page.components
|
||||
self.edit_notes(components)
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
components = self.note_unit_page.refresh()
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
self.course_nav.go_to_sequential_position(1)
|
||||
components = self.note_unit_page.components
|
||||
self.assert_text_in_notes(self.note_unit_page.notes)
|
||||
|
||||
def test_can_delete_notes(self):
|
||||
"""
|
||||
Scenario: User can delete notes.
|
||||
Given I have a course with 3 components with notes
|
||||
And I open the unit with 2 annotatable components
|
||||
When I remove all notes on the page
|
||||
Then I do not see any notes on the page
|
||||
When I change sequential position to "2"
|
||||
And I remove all notes on the page
|
||||
Then I do not see any notes on the page
|
||||
When I refresh the page
|
||||
Then I do not see any notes on the page
|
||||
When I change sequential position to "1"
|
||||
Then I do not see any notes on the page
|
||||
"""
|
||||
self._add_notes()
|
||||
self.note_unit_page.visit()
|
||||
|
||||
components = self.note_unit_page.components
|
||||
self.remove_notes(components)
|
||||
self.assert_notes_are_removed(components)
|
||||
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
components = self.note_unit_page.components
|
||||
self.remove_notes(components)
|
||||
self.assert_notes_are_removed(components)
|
||||
|
||||
components = self.note_unit_page.refresh()
|
||||
self.assert_notes_are_removed(components)
|
||||
|
||||
self.course_nav.go_to_sequential_position(1)
|
||||
components = self.note_unit_page.components
|
||||
self.assert_notes_are_removed(components)
|
||||
|
||||
|
||||
class EdxNotesPageTest(EdxNotesTestMixin):
|
||||
"""
|
||||
Tests for Notes page.
|
||||
"""
|
||||
def _add_notes(self, notes_list):
|
||||
self.edxnotes_fixture.create_notes(notes_list)
|
||||
self.edxnotes_fixture.install()
|
||||
|
||||
def _add_default_notes(self):
|
||||
xblocks = self.course_fixture.get_nested_xblocks(category="html")
|
||||
self._add_notes([
|
||||
Note(
|
||||
usage_id=xblocks[4].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="First note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2011, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[2].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="",
|
||||
quote=u"Annotate this text",
|
||||
updated=datetime(2012, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[0].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Third note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2013, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
ranges=[Range(startOffset=0, endOffset=18)],
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[3].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Fourth note",
|
||||
quote="",
|
||||
updated=datetime(2014, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[1].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Fifth note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2015, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
),
|
||||
])
|
||||
|
||||
def assertNoteContent(self, item, text=None, quote=None, unit_name=None, time_updated=None):
|
||||
if item.text is not None:
|
||||
self.assertEqual(text, item.text)
|
||||
else:
|
||||
self.assertIsNone(text)
|
||||
if item.quote is not None:
|
||||
self.assertIn(quote, item.quote)
|
||||
else:
|
||||
self.assertIsNone(quote)
|
||||
self.assertEqual(unit_name, item.unit_name)
|
||||
self.assertEqual(time_updated, item.time_updated)
|
||||
|
||||
def assertGroupContent(self, item, title=None, subtitles=None):
|
||||
self.assertEqual(item.title, title)
|
||||
self.assertEqual(item.subtitles, subtitles)
|
||||
|
||||
def assertSectionContent(self, item, title=None, notes=None):
|
||||
self.assertEqual(item.title, title)
|
||||
self.assertEqual(item.notes, notes)
|
||||
|
||||
def test_no_content(self):
|
||||
"""
|
||||
Scenario: User can see `No content` message.
|
||||
Given I have a course without notes
|
||||
When I open Notes page
|
||||
Then I see only "You do not have any notes within the course." message
|
||||
"""
|
||||
self.notes_page.visit()
|
||||
self.assertIn(
|
||||
"You have not made any notes in this course yet. Other students in this course are using notes to:",
|
||||
self.notes_page.no_content_text)
|
||||
|
||||
def test_recent_activity_view(self):
|
||||
"""
|
||||
Scenario: User can view all notes by recent activity.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
Then I see 5 notes sorted by the updated date
|
||||
And I see correct content in the notes
|
||||
"""
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit()
|
||||
notes = self.notes_page.notes
|
||||
self.assertEqual(len(notes), 5)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[0],
|
||||
quote=u"Annotate this text",
|
||||
text=u"Fifth note",
|
||||
unit_name="Test Unit 1",
|
||||
time_updated="Jan 01, 2015 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[1],
|
||||
text=u"Fourth note",
|
||||
unit_name="Test Unit 3",
|
||||
time_updated="Jan 01, 2014 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[2],
|
||||
quote="Annotate this text",
|
||||
text=u"Third note",
|
||||
unit_name="Test Unit 1",
|
||||
time_updated="Jan 01, 2013 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[3],
|
||||
quote=u"Annotate this text",
|
||||
unit_name="Test Unit 2",
|
||||
time_updated="Jan 01, 2012 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[4],
|
||||
quote=u"Annotate this text",
|
||||
text=u"First note",
|
||||
unit_name="Test Unit 4",
|
||||
time_updated="Jan 01, 2011 at 01:01 UTC"
|
||||
)
|
||||
|
||||
def test_course_structure_view(self):
|
||||
"""
|
||||
Scenario: User can view all notes by location in Course.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
And I switch to "Location in Course" view
|
||||
Then I see 2 groups, 3 sections and 5 notes
|
||||
And I see correct content in the notes and groups
|
||||
"""
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit().switch_to_tab("structure")
|
||||
|
||||
notes = self.notes_page.notes
|
||||
groups = self.notes_page.groups
|
||||
sections = self.notes_page.sections
|
||||
self.assertEqual(len(notes), 5)
|
||||
self.assertEqual(len(groups), 2)
|
||||
self.assertEqual(len(sections), 3)
|
||||
|
||||
self.assertGroupContent(
|
||||
groups[0],
|
||||
title=u"Test Section 1",
|
||||
subtitles=[u"Test Subsection 1", u"Test Subsection 2"]
|
||||
)
|
||||
|
||||
self.assertSectionContent(
|
||||
sections[0],
|
||||
title=u"Test Subsection 1",
|
||||
notes=[u"Fifth note", u"Third note", None]
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[0],
|
||||
quote=u"Annotate this text",
|
||||
text=u"Fifth note",
|
||||
unit_name="Test Unit 1",
|
||||
time_updated="Jan 01, 2015 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[1],
|
||||
quote=u"Annotate this text",
|
||||
text=u"Third note",
|
||||
unit_name="Test Unit 1",
|
||||
time_updated="Jan 01, 2013 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[2],
|
||||
quote=u"Annotate this text",
|
||||
unit_name="Test Unit 2",
|
||||
time_updated="Jan 01, 2012 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertSectionContent(
|
||||
sections[1],
|
||||
title=u"Test Subsection 2",
|
||||
notes=[u"Fourth note"]
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[3],
|
||||
text=u"Fourth note",
|
||||
unit_name="Test Unit 3",
|
||||
time_updated="Jan 01, 2014 at 01:01 UTC"
|
||||
)
|
||||
|
||||
self.assertGroupContent(
|
||||
groups[1],
|
||||
title=u"Test Section 2",
|
||||
subtitles=[u"Test Subsection 3"],
|
||||
)
|
||||
|
||||
self.assertSectionContent(
|
||||
sections[2],
|
||||
title=u"Test Subsection 3",
|
||||
notes=[u"First note"]
|
||||
)
|
||||
|
||||
self.assertNoteContent(
|
||||
notes[4],
|
||||
quote=u"Annotate this text",
|
||||
text=u"First note",
|
||||
unit_name="Test Unit 4",
|
||||
time_updated="Jan 01, 2011 at 01:01 UTC"
|
||||
)
|
||||
|
||||
def test_easy_access_from_notes_page(self):
|
||||
"""
|
||||
Scenario: Ensure that the link to the Unit works correctly.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
And I click on the first unit link
|
||||
Then I see correct text on the unit page
|
||||
When go back to the Notes page
|
||||
And I switch to "Location in Course" view
|
||||
And I click on the second unit link
|
||||
Then I see correct text on the unit page
|
||||
When go back to the Notes page
|
||||
And I run the search with "Fifth" query
|
||||
And I click on the first unit link
|
||||
Then I see correct text on the unit page
|
||||
"""
|
||||
def assert_page(note):
|
||||
quote = note.quote
|
||||
note.go_to_unit()
|
||||
self.courseware_page.wait_for_page()
|
||||
self.assertIn(quote, self.courseware_page.xblock_component_html_content())
|
||||
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit()
|
||||
note = self.notes_page.notes[0]
|
||||
assert_page(note)
|
||||
|
||||
self.notes_page.visit().switch_to_tab("structure")
|
||||
note = self.notes_page.notes[1]
|
||||
assert_page(note)
|
||||
|
||||
self.notes_page.visit().search("Fifth")
|
||||
note = self.notes_page.notes[0]
|
||||
assert_page(note)
|
||||
|
||||
def test_search_behaves_correctly(self):
|
||||
"""
|
||||
Scenario: Searching behaves correctly.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
When I run the search with " " query
|
||||
Then I see the following error message "Please enter a term in the search field."
|
||||
And I do not see "Search Results" tab
|
||||
When I run the search with "note" query
|
||||
Then I see that error message disappears
|
||||
And I see that "Search Results" tab appears with 4 notes found
|
||||
"""
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit()
|
||||
# Run the search with whitespaces only
|
||||
self.notes_page.search(" ")
|
||||
# Displays error message
|
||||
self.assertTrue(self.notes_page.is_error_visible)
|
||||
self.assertEqual(self.notes_page.error_text, u"Please enter a term in the search field.")
|
||||
# Search results tab does not appear
|
||||
self.assertNotIn(u"Search Results", self.notes_page.tabs)
|
||||
# Run the search with correct query
|
||||
self.notes_page.search("note")
|
||||
# Error message disappears
|
||||
self.assertFalse(self.notes_page.is_error_visible)
|
||||
self.assertIn(u"Search Results", self.notes_page.tabs)
|
||||
self.assertEqual(len(self.notes_page.notes), 4)
|
||||
|
||||
def test_tabs_behaves_correctly(self):
|
||||
"""
|
||||
Scenario: Tabs behaves correctly.
|
||||
Given I have a course with 5 notes
|
||||
When I open Notes page
|
||||
Then I see only "Recent Activity" and "Location in Course" tabs
|
||||
When I run the search with "note" query
|
||||
And I see that "Search Results" tab appears with 4 notes found
|
||||
Then I switch to "Recent Activity" tab
|
||||
And I see all 5 notes
|
||||
Then I switch to "Location in Course" tab
|
||||
And I see all 2 groups and 5 notes
|
||||
When I switch back to "Search Results" tab
|
||||
Then I can still see 4 notes found
|
||||
When I close "Search Results" tab
|
||||
Then I see that "Recent Activity" tab becomes active
|
||||
And "Search Results" tab disappears
|
||||
And I see all 5 notes
|
||||
"""
|
||||
self._add_default_notes()
|
||||
self.notes_page.visit()
|
||||
|
||||
# We're on Recent Activity tab.
|
||||
self.assertEqual(len(self.notes_page.tabs), 2)
|
||||
self.assertEqual([u"Recent Activity", u"Location in Course"], self.notes_page.tabs)
|
||||
self.notes_page.search("note")
|
||||
# We're on Search Results tab
|
||||
self.assertEqual(len(self.notes_page.tabs), 3)
|
||||
self.assertIn(u"Search Results", self.notes_page.tabs)
|
||||
self.assertEqual(len(self.notes_page.notes), 4)
|
||||
# We can switch on Recent Activity tab and back.
|
||||
self.notes_page.switch_to_tab("recent")
|
||||
self.assertEqual(len(self.notes_page.notes), 5)
|
||||
self.notes_page.switch_to_tab("structure")
|
||||
self.assertEqual(len(self.notes_page.groups), 2)
|
||||
self.assertEqual(len(self.notes_page.notes), 5)
|
||||
self.notes_page.switch_to_tab("search")
|
||||
self.assertEqual(len(self.notes_page.notes), 4)
|
||||
# Can close search results page
|
||||
self.notes_page.close_tab("search")
|
||||
self.assertEqual(len(self.notes_page.tabs), 2)
|
||||
self.assertNotIn(u"Search Results", self.notes_page.tabs)
|
||||
self.assertEqual(len(self.notes_page.notes), 5)
|
||||
|
||||
def test_open_note_when_accessed_from_notes_page(self):
|
||||
"""
|
||||
Scenario: Ensure that the link to the Unit opens a note only once.
|
||||
Given I have a course with 2 sequentials that contain respectively one note and two notes
|
||||
When I open Notes page
|
||||
And I click on the first unit link
|
||||
Then I see the note opened on the unit page
|
||||
When I switch to the second sequential
|
||||
I do not see any note opened
|
||||
When I switch back to first sequential
|
||||
I do not see any note opened
|
||||
"""
|
||||
xblocks = self.course_fixture.get_nested_xblocks(category="html")
|
||||
self._add_notes([
|
||||
Note(
|
||||
usage_id=xblocks[1].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Third note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2012, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
ranges=[Range(startOffset=0, endOffset=19)],
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[2].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="Second note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2013, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
ranges=[Range(startOffset=0, endOffset=19)],
|
||||
),
|
||||
Note(
|
||||
usage_id=xblocks[0].locator,
|
||||
user=self.username,
|
||||
course_id=self.course_fixture._course_key,
|
||||
text="First note",
|
||||
quote="Annotate this text",
|
||||
updated=datetime(2014, 1, 1, 1, 1, 1, 1).isoformat(),
|
||||
ranges=[Range(startOffset=0, endOffset=19)],
|
||||
),
|
||||
])
|
||||
self.notes_page.visit()
|
||||
item = self.notes_page.notes[0]
|
||||
item.go_to_unit()
|
||||
self.courseware_page.wait_for_page()
|
||||
note = self.note_unit_page.notes[0]
|
||||
self.assertTrue(note.is_visible)
|
||||
note = self.note_unit_page.notes[1]
|
||||
self.assertFalse(note.is_visible)
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
note = self.note_unit_page.notes[0]
|
||||
self.assertFalse(note.is_visible)
|
||||
self.course_nav.go_to_sequential_position(1)
|
||||
note = self.note_unit_page.notes[0]
|
||||
self.assertFalse(note.is_visible)
|
||||
|
||||
|
||||
class EdxNotesToggleSingleNoteTest(EdxNotesTestMixin):
|
||||
"""
|
||||
Tests for toggling single annotation.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(EdxNotesToggleSingleNoteTest, self).setUp()
|
||||
self._add_notes()
|
||||
self.note_unit_page.visit()
|
||||
|
||||
def test_can_toggle_by_clicking_on_highlighted_text(self):
|
||||
"""
|
||||
Scenario: User can toggle a single note by clicking on highlighted text.
|
||||
Given I have a course with components with notes
|
||||
When I click on highlighted text
|
||||
And I move mouse out of the note
|
||||
Then I see that the note is still shown
|
||||
When I click outside the note
|
||||
Then I see the the note is closed
|
||||
"""
|
||||
note = self.note_unit_page.notes[0]
|
||||
|
||||
note.click_on_highlight()
|
||||
self.note_unit_page.move_mouse_to("body")
|
||||
self.assertTrue(note.is_visible)
|
||||
self.note_unit_page.click("body")
|
||||
self.assertFalse(note.is_visible)
|
||||
|
||||
def test_can_toggle_by_clicking_on_the_note(self):
|
||||
"""
|
||||
Scenario: User can toggle a single note by clicking on the note.
|
||||
Given I have a course with components with notes
|
||||
When I click on the note
|
||||
And I move mouse out of the note
|
||||
Then I see that the note is still shown
|
||||
When I click outside the note
|
||||
Then I see the the note is closed
|
||||
"""
|
||||
note = self.note_unit_page.notes[0]
|
||||
|
||||
note.show().click_on_viewer()
|
||||
self.note_unit_page.move_mouse_to("body")
|
||||
self.assertTrue(note.is_visible)
|
||||
self.note_unit_page.click("body")
|
||||
self.assertFalse(note.is_visible)
|
||||
|
||||
def test_interaction_between_notes(self):
|
||||
"""
|
||||
Scenario: Interactions between notes works well.
|
||||
Given I have a course with components with notes
|
||||
When I click on highlighted text in the first component
|
||||
And I move mouse out of the note
|
||||
Then I see that the note is still shown
|
||||
When I click on highlighted text in the second component
|
||||
Then I do not see any notes
|
||||
When I click again on highlighted text in the second component
|
||||
Then I see appropriate note
|
||||
"""
|
||||
note_1 = self.note_unit_page.notes[0]
|
||||
note_2 = self.note_unit_page.notes[1]
|
||||
|
||||
note_1.click_on_highlight()
|
||||
self.note_unit_page.move_mouse_to("body")
|
||||
self.assertTrue(note_1.is_visible)
|
||||
|
||||
note_2.click_on_highlight()
|
||||
self.assertFalse(note_1.is_visible)
|
||||
self.assertFalse(note_2.is_visible)
|
||||
|
||||
note_2.click_on_highlight()
|
||||
self.assertTrue(note_2.is_visible)
|
||||
|
||||
|
||||
class EdxNotesToggleNotesTest(EdxNotesTestMixin):
|
||||
"""
|
||||
Tests for toggling visibility of all notes.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(EdxNotesToggleNotesTest, self).setUp()
|
||||
self._add_notes()
|
||||
self.note_unit_page.visit()
|
||||
|
||||
def test_can_disable_all_notes(self):
|
||||
"""
|
||||
Scenario: User can disable all notes.
|
||||
Given I have a course with components with notes
|
||||
And I open the unit with annotatable components
|
||||
When I click on "Show notes" checkbox
|
||||
Then I do not see any notes on the sequential position
|
||||
When I change sequential position to "2"
|
||||
Then I still do not see any notes on the sequential position
|
||||
When I go to "Test Subsection 2" subsection
|
||||
Then I do not see any notes on the subsection
|
||||
"""
|
||||
# Disable all notes
|
||||
self.note_unit_page.toggle_visibility()
|
||||
self.assertEqual(len(self.note_unit_page.notes), 0)
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
self.assertEqual(len(self.note_unit_page.notes), 0)
|
||||
self.course_nav.go_to_section(u"Test Section 1", u"Test Subsection 2")
|
||||
self.assertEqual(len(self.note_unit_page.notes), 0)
|
||||
|
||||
def test_can_reenable_all_notes(self):
|
||||
"""
|
||||
Scenario: User can toggle notes visibility.
|
||||
Given I have a course with components with notes
|
||||
And I open the unit with annotatable components
|
||||
When I click on "Show notes" checkbox
|
||||
Then I do not see any notes on the sequential position
|
||||
When I click on "Show notes" checkbox again
|
||||
Then I see that all notes appear
|
||||
When I change sequential position to "2"
|
||||
Then I still can see all notes on the sequential position
|
||||
When I go to "Test Subsection 2" subsection
|
||||
Then I can see all notes on the subsection
|
||||
"""
|
||||
# Disable notes
|
||||
self.note_unit_page.toggle_visibility()
|
||||
self.assertEqual(len(self.note_unit_page.notes), 0)
|
||||
# Enable notes to make sure that I can enable notes without refreshing
|
||||
# the page.
|
||||
self.note_unit_page.toggle_visibility()
|
||||
self.assertGreater(len(self.note_unit_page.notes), 0)
|
||||
self.course_nav.go_to_sequential_position(2)
|
||||
self.assertGreater(len(self.note_unit_page.notes), 0)
|
||||
self.course_nav.go_to_section(u"Test Section 1", u"Test Subsection 2")
|
||||
self.assertGreater(len(self.note_unit_page.notes), 0)
|
||||
15
common/test/db_fixtures/edx-notes_client.json
Normal file
15
common/test/db_fixtures/edx-notes_client.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"pk": 1,
|
||||
"model": "oauth2.client",
|
||||
"fields": {
|
||||
"name": "edx-notes",
|
||||
"url": "http://example.com/",
|
||||
"client_type": 1,
|
||||
"redirect_uri": "http://example.com/welcome",
|
||||
"user": null,
|
||||
"client_id": "22a9e15e3d3b115e4d43",
|
||||
"client_secret": "7969f769a1fe21ecd6cf8a1c105f250f70a27131"
|
||||
}
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user