Merge pull request #6321 from edx/feature/edxnotes

TNL-213: Student Notes
This commit is contained in:
Tim Babych
2015-01-15 02:02:13 +02:00
125 changed files with 9458 additions and 358 deletions

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

View File

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

View File

@@ -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

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

View 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

View File

@@ -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
"""

View File

@@ -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

View File

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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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

File diff suppressed because one or more lines are too long

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

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

File diff suppressed because one or more lines are too long

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

View File

@@ -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')

View 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

View File

@@ -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):
"""

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

View File

@@ -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.

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

View 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"
}
}
]