Merge branch 'master' into ux/marco/forums-icons
This commit is contained in:
@@ -185,6 +185,11 @@ def _combined_open_ended_grading(tab, user, course, active_page):
|
||||
return tab
|
||||
return []
|
||||
|
||||
def _notes_tab(tab, user, course, active_page):
|
||||
if user.is_authenticated() and settings.MITX_FEATURES.get('ENABLE_STUDENT_NOTES'):
|
||||
link = reverse('notes', args=[course.id])
|
||||
return [CourseTab(tab['name'], link, active_page == 'notes')]
|
||||
return []
|
||||
|
||||
#### Validators
|
||||
|
||||
@@ -227,6 +232,7 @@ VALID_TAB_TYPES = {
|
||||
'peer_grading': TabImpl(null_validator, _peer_grading),
|
||||
'staff_grading': TabImpl(null_validator, _staff_grading),
|
||||
'open_ended': TabImpl(null_validator, _combined_open_ended_grading),
|
||||
'notes': TabImpl(null_validator, _notes_tab)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -399,6 +399,14 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase):
|
||||
import_from_xml(module_store, TEST_DATA_DIR, ['toy'])
|
||||
self.check_random_page_loads(module_store)
|
||||
|
||||
def test_full_textbooks_loads(self):
|
||||
module_store = modulestore()
|
||||
import_from_xml(module_store, TEST_DATA_DIR, ['full'])
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class TestNavigation(LoginEnrollmentTestCase):
|
||||
@@ -623,8 +631,8 @@ class TestViewAuth(LoginEnrollmentTestCase):
|
||||
urls = reverse_urls(['info', 'progress'], course)
|
||||
urls.extend([
|
||||
reverse('book', kwargs={'course_id': course.id,
|
||||
'book_index': book.title})
|
||||
for book in course.textbooks
|
||||
'book_index': index})
|
||||
for index, book in enumerate(course.textbooks)
|
||||
])
|
||||
return urls
|
||||
|
||||
@@ -635,8 +643,6 @@ class TestViewAuth(LoginEnrollmentTestCase):
|
||||
"""
|
||||
urls = reverse_urls(['about_course'], course)
|
||||
urls.append(reverse('courses'))
|
||||
# Need separate test for change_enrollment, since it's a POST view
|
||||
#urls.append(reverse('change_enrollment'))
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
57
lms/djangoapps/notes/README.md
Normal file
57
lms/djangoapps/notes/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
Notes Django App
|
||||
================
|
||||
|
||||
This is a django application that stores and displays notes that students make while reading static HTML book(s) in their courseware. Note taking functionality in the static HTML book(s) is handled by a wrapper script around [annotator.js](http://okfnlabs.org/annotator/), which interfaces with the API provided by this application to store and retrieve notes.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
To use this application, course staff must opt-in by doing the following:
|
||||
|
||||
* Login to [Studio](http://studio.edx.org/).
|
||||
* Go to *Course Settings* -> *Advanced Settings*
|
||||
* Find the ```advanced_modules``` policy key and in the policy value field, add ```"notes"``` to the list.
|
||||
* Save the course settings.
|
||||
|
||||
The result of following these steps is that you should see a new tab appear in the courseware named *My Notes*. This will display a journal of notes that the student has created in the static HTML book(s). Second, when you highlight text in the static HTML book(s), a dialog will appear. You can enter some notes and tags and save it. The note will appear highlighted in the text and will also be saved to the journal.
|
||||
|
||||
To disable the *My Notes* tab and notes in the static HTML book(s), simply reverse the above steps (i.e. remove ```"notes"``` from the ```advanced_modules``` policy setting).
|
||||
|
||||
### Caveats and Limitations
|
||||
|
||||
* Notes are private to each student.
|
||||
* Sharing and replying to notes is not supported.
|
||||
* The student *My Notes* interface is very limited.
|
||||
* There is no instructor interface to view student notes.
|
||||
|
||||
Developer Overview
|
||||
------------------
|
||||
|
||||
### Quickstart
|
||||
|
||||
```
|
||||
$ rake django-admin[syncdb]
|
||||
$ rake django-admin[migrate]
|
||||
```
|
||||
|
||||
Then follow the steps above to enable the *My Notes* tab or manually add a tab to the policy tab configuration with ```{"type": "notes", "name": "My Notes"}```.
|
||||
|
||||
### App Directory Structure:
|
||||
|
||||
lms/djangoapps/notes:
|
||||
|
||||
* api.py - API used by annotator.js on the frontend
|
||||
* models.py - Contains note model for storing notes
|
||||
* tests.py - Unit tests
|
||||
* views.py - View to display the journal of notes (i.e. *My Notes* tab)
|
||||
* urls.py - Maps the API and View routes.
|
||||
* utils.py - Contains method for checking if the course has this app enabled. Intended to be public to other modules.
|
||||
|
||||
Also requires:
|
||||
|
||||
* lms/static/coffee/src/notes.coffee -- wrapper around annotator.js
|
||||
* lms/templates/notes.html -- used by views.py to display the notes
|
||||
|
||||
Interacts with:
|
||||
|
||||
* lms/djangoapps/staticbook - the html static book checks to see if notes is enabled and has some logic to enable/disable accordingly
|
||||
0
lms/djangoapps/notes/__init__.py
Normal file
0
lms/djangoapps/notes/__init__.py
Normal file
251
lms/djangoapps/notes/api.py
Normal file
251
lms/djangoapps/notes/api.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from notes.models import Note
|
||||
from notes.utils import notes_enabled_for_course
|
||||
from courseware.courses import get_course_with_access
|
||||
|
||||
import json
|
||||
import logging
|
||||
import collections
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
API_SETTINGS = {
|
||||
'META': {'name': 'Notes API', 'version': 1},
|
||||
|
||||
# Maps resources to HTTP methods and actions
|
||||
'RESOURCE_MAP': {
|
||||
'root': {'GET': 'root'},
|
||||
'notes': {'GET': 'index', 'POST': 'create'},
|
||||
'note': {'GET': 'read', 'PUT': 'update', 'DELETE': 'delete'},
|
||||
'search': {'GET': 'search'},
|
||||
},
|
||||
|
||||
# Cap the number of notes that can be returned in one request
|
||||
'MAX_NOTE_LIMIT': 1000,
|
||||
}
|
||||
|
||||
# Wrapper class for HTTP response and data. All API actions are expected to return this.
|
||||
ApiResponse = collections.namedtuple('ApiResponse', ['http_response', 'data'])
|
||||
|
||||
#----------------------------------------------------------------------#
|
||||
# API requests are routed through api_request() using the resource map.
|
||||
|
||||
|
||||
def api_enabled(request, course_id):
|
||||
'''
|
||||
Returns True if the api is enabled for the course, otherwise False.
|
||||
'''
|
||||
course = _get_course(request, course_id)
|
||||
return notes_enabled_for_course(course)
|
||||
|
||||
|
||||
@login_required
|
||||
def api_request(request, course_id, **kwargs):
|
||||
'''
|
||||
Routes API requests to the appropriate action method and returns JSON.
|
||||
Raises a 404 if the requested resource does not exist or notes are
|
||||
disabled for the course.
|
||||
'''
|
||||
|
||||
# Verify that the api should be accessible to this course
|
||||
if not api_enabled(request, course_id):
|
||||
log.debug('Notes are disabled for course: {0}'.format(course_id))
|
||||
raise Http404
|
||||
|
||||
# Locate the requested resource
|
||||
resource_map = API_SETTINGS.get('RESOURCE_MAP', {})
|
||||
resource_name = kwargs.pop('resource')
|
||||
resource_method = request.method
|
||||
resource = resource_map.get(resource_name)
|
||||
|
||||
if resource is None:
|
||||
log.debug('Resource "{0}" does not exist'.format(resource_name))
|
||||
raise Http404
|
||||
|
||||
if resource_method not in resource.keys():
|
||||
log.debug('Resource "{0}" does not support method "{1}"'.format(resource_name, resource_method))
|
||||
raise Http404
|
||||
|
||||
# Execute the action associated with the resource
|
||||
func = resource.get(resource_method)
|
||||
module = globals()
|
||||
if func not in module:
|
||||
log.debug('Function "{0}" does not exist for request {1} {2}'.format(func, resource_method, resource_name))
|
||||
raise Http404
|
||||
|
||||
log.debug('API request: {0} {1}'.format(resource_method, resource_name))
|
||||
|
||||
api_response = module[func](request, course_id, **kwargs)
|
||||
http_response = api_format(api_response)
|
||||
|
||||
return http_response
|
||||
|
||||
|
||||
def api_format(api_response):
|
||||
'''
|
||||
Takes an ApiResponse and returns an HttpResponse.
|
||||
'''
|
||||
http_response = api_response.http_response
|
||||
content_type = 'application/json'
|
||||
content = ''
|
||||
|
||||
# not doing a strict boolean check on data becuase it could be an empty list
|
||||
if api_response.data is not None and api_response.data != '':
|
||||
content = json.dumps(api_response.data)
|
||||
|
||||
http_response['Content-type'] = content_type
|
||||
http_response.content = content
|
||||
|
||||
log.debug('API response type: {0} content: {1}'.format(content_type, content))
|
||||
|
||||
return http_response
|
||||
|
||||
|
||||
def _get_course(request, course_id):
|
||||
'''
|
||||
Helper function to load and return a user's course.
|
||||
'''
|
||||
return get_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
#----------------------------------------------------------------------#
|
||||
# API actions exposed via the resource map.
|
||||
|
||||
|
||||
def index(request, course_id):
|
||||
'''
|
||||
Returns a list of annotation objects.
|
||||
'''
|
||||
MAX_LIMIT = API_SETTINGS.get('MAX_NOTE_LIMIT')
|
||||
|
||||
notes = Note.objects.order_by('id').filter(course_id=course_id,
|
||||
user=request.user)[:MAX_LIMIT]
|
||||
|
||||
return ApiResponse(http_response=HttpResponse(), data=[note.as_dict() for note in notes])
|
||||
|
||||
|
||||
def create(request, course_id):
|
||||
'''
|
||||
Receives an annotation object to create and returns a 303 with the read location.
|
||||
'''
|
||||
note = Note(course_id=course_id, user=request.user)
|
||||
|
||||
try:
|
||||
note.clean(request.body)
|
||||
except ValidationError as e:
|
||||
log.debug(e)
|
||||
return ApiResponse(http_response=HttpResponse('', status=400), data=None)
|
||||
|
||||
note.save()
|
||||
response = HttpResponse('', status=303)
|
||||
response['Location'] = note.get_absolute_url()
|
||||
|
||||
return ApiResponse(http_response=response, data=None)
|
||||
|
||||
|
||||
def read(request, course_id, note_id):
|
||||
'''
|
||||
Returns a single annotation object.
|
||||
'''
|
||||
try:
|
||||
note = Note.objects.get(id=note_id)
|
||||
except Note.DoesNotExist:
|
||||
return ApiResponse(http_response=HttpResponse('', status=404), data=None)
|
||||
|
||||
if note.user.id != request.user.id:
|
||||
return ApiResponse(http_response=HttpResponse('', status=403), data=None)
|
||||
|
||||
return ApiResponse(http_response=HttpResponse(), data=note.as_dict())
|
||||
|
||||
|
||||
def update(request, course_id, note_id):
|
||||
'''
|
||||
Updates an annotation object and returns a 303 with the read location.
|
||||
'''
|
||||
try:
|
||||
note = Note.objects.get(id=note_id)
|
||||
except Note.DoesNotExist:
|
||||
return ApiResponse(http_response=HttpResponse('', status=404), data=None)
|
||||
|
||||
if note.user.id != request.user.id:
|
||||
return ApiResponse(http_response=HttpResponse('', status=403), data=None)
|
||||
|
||||
try:
|
||||
note.clean(request.body)
|
||||
except ValidationError as e:
|
||||
log.debug(e)
|
||||
return ApiResponse(http_response=HttpResponse('', status=400), data=None)
|
||||
|
||||
note.save()
|
||||
|
||||
response = HttpResponse('', status=303)
|
||||
response['Location'] = note.get_absolute_url()
|
||||
|
||||
return ApiResponse(http_response=response, data=None)
|
||||
|
||||
|
||||
def delete(request, course_id, note_id):
|
||||
'''
|
||||
Deletes the annotation object and returns a 204 with no content.
|
||||
'''
|
||||
try:
|
||||
note = Note.objects.get(id=note_id)
|
||||
except Note.DoesNotExist:
|
||||
return ApiResponse(http_response=HttpResponse('', status=404), data=None)
|
||||
|
||||
if note.user.id != request.user.id:
|
||||
return ApiResponse(http_response=HttpResponse('', status=403), data=None)
|
||||
|
||||
note.delete()
|
||||
|
||||
return ApiResponse(http_response=HttpResponse('', status=204), data=None)
|
||||
|
||||
|
||||
def search(request, course_id):
|
||||
'''
|
||||
Returns a subset of annotation objects based on a search query.
|
||||
'''
|
||||
MAX_LIMIT = API_SETTINGS.get('MAX_NOTE_LIMIT')
|
||||
|
||||
# search parameters
|
||||
offset = request.GET.get('offset', '')
|
||||
limit = request.GET.get('limit', '')
|
||||
uri = request.GET.get('uri', '')
|
||||
|
||||
# validate search parameters
|
||||
if offset.isdigit():
|
||||
offset = int(offset)
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
if limit.isdigit():
|
||||
limit = int(limit)
|
||||
if limit == 0 or limit > MAX_LIMIT:
|
||||
limit = MAX_LIMIT
|
||||
else:
|
||||
limit = MAX_LIMIT
|
||||
|
||||
# set filters
|
||||
filters = {'course_id': course_id, 'user': request.user}
|
||||
if uri != '':
|
||||
filters['uri'] = uri
|
||||
|
||||
# retrieve notes
|
||||
notes = Note.objects.order_by('id').filter(**filters)
|
||||
total = notes.count()
|
||||
rows = notes[offset:offset + limit]
|
||||
result = {
|
||||
'total': total,
|
||||
'rows': [note.as_dict() for note in rows]
|
||||
}
|
||||
|
||||
return ApiResponse(http_response=HttpResponse(), data=result)
|
||||
|
||||
|
||||
def root(request, course_id):
|
||||
'''
|
||||
Returns version information about the API.
|
||||
'''
|
||||
return ApiResponse(http_response=HttpResponse(), data=API_SETTINGS.get('META'))
|
||||
90
lms/djangoapps/notes/migrations/0001_initial.py
Normal file
90
lms/djangoapps/notes/migrations/0001_initial.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'Note'
|
||||
db.create_table('notes_note', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('uri', self.gf('django.db.models.fields.CharField')(max_length=1024, db_index=True)),
|
||||
('text', self.gf('django.db.models.fields.TextField')(default='')),
|
||||
('quote', self.gf('django.db.models.fields.TextField')(default='')),
|
||||
('range_start', self.gf('django.db.models.fields.CharField')(max_length=2048)),
|
||||
('range_start_offset', self.gf('django.db.models.fields.IntegerField')()),
|
||||
('range_end', self.gf('django.db.models.fields.CharField')(max_length=2048)),
|
||||
('range_end_offset', self.gf('django.db.models.fields.IntegerField')()),
|
||||
('tags', self.gf('django.db.models.fields.TextField')(default='')),
|
||||
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)),
|
||||
('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
|
||||
))
|
||||
db.send_create_signal('notes', ['Note'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'Note'
|
||||
db.delete_table('notes_note')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'notes.note': {
|
||||
'Meta': {'object_name': 'Note'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'quote': ('django.db.models.fields.TextField', [], {'default': "''"}),
|
||||
'range_end': ('django.db.models.fields.CharField', [], {'max_length': '2048'}),
|
||||
'range_end_offset': ('django.db.models.fields.IntegerField', [], {}),
|
||||
'range_start': ('django.db.models.fields.CharField', [], {'max_length': '2048'}),
|
||||
'range_start_offset': ('django.db.models.fields.IntegerField', [], {}),
|
||||
'tags': ('django.db.models.fields.TextField', [], {'default': "''"}),
|
||||
'text': ('django.db.models.fields.TextField', [], {'default': "''"}),
|
||||
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'uri': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'db_index': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['notes']
|
||||
0
lms/djangoapps/notes/migrations/__init__.py
Normal file
0
lms/djangoapps/notes/migrations/__init__.py
Normal file
81
lms/djangoapps/notes/models.py
Normal file
81
lms/djangoapps/notes/models.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.html import strip_tags
|
||||
import json
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
user = models.ForeignKey(User, db_index=True)
|
||||
course_id = models.CharField(max_length=255, db_index=True)
|
||||
uri = models.CharField(max_length=1024, db_index=True)
|
||||
text = models.TextField(default="")
|
||||
quote = models.TextField(default="")
|
||||
range_start = models.CharField(max_length=2048) # xpath string
|
||||
range_start_offset = models.IntegerField()
|
||||
range_end = models.CharField(max_length=2048) # xpath string
|
||||
range_end_offset = models.IntegerField()
|
||||
tags = models.TextField(default="") # comma-separated string
|
||||
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
|
||||
updated = models.DateTimeField(auto_now=True, db_index=True)
|
||||
|
||||
def clean(self, json_body):
|
||||
'''
|
||||
Cleans the note object or raises a ValidationError.
|
||||
'''
|
||||
if json_body is None:
|
||||
raise ValidationError('Note must have a body.')
|
||||
|
||||
body = json.loads(json_body)
|
||||
if not type(body) is dict:
|
||||
raise ValidationError('Note body must be a dictionary.')
|
||||
|
||||
# NOTE: all three of these fields should be considered user input
|
||||
# and may be output back to the user, so we need to sanitize them.
|
||||
# These fields should only contain _plain text_.
|
||||
self.uri = strip_tags(body.get('uri', ''))
|
||||
self.text = strip_tags(body.get('text', ''))
|
||||
self.quote = strip_tags(body.get('quote', ''))
|
||||
|
||||
ranges = body.get('ranges')
|
||||
if ranges is None or len(ranges) != 1:
|
||||
raise ValidationError('Note must contain exactly one range.')
|
||||
|
||||
self.range_start = ranges[0]['start']
|
||||
self.range_start_offset = ranges[0]['startOffset']
|
||||
self.range_end = ranges[0]['end']
|
||||
self.range_end_offset = ranges[0]['endOffset']
|
||||
|
||||
self.tags = ""
|
||||
tags = [strip_tags(tag) for tag in body.get('tags', [])]
|
||||
if len(tags) > 0:
|
||||
self.tags = ",".join(tags)
|
||||
|
||||
def get_absolute_url(self):
|
||||
'''
|
||||
Returns the aboslute url for the note object.
|
||||
'''
|
||||
kwargs = {'course_id': self.course_id, 'note_id': str(self.pk)}
|
||||
return reverse('notes_api_note', kwargs=kwargs)
|
||||
|
||||
def as_dict(self):
|
||||
'''
|
||||
Returns the note object as a dictionary.
|
||||
'''
|
||||
return {
|
||||
'id': self.pk,
|
||||
'user_id': self.user.pk,
|
||||
'uri': self.uri,
|
||||
'text': self.text,
|
||||
'quote': self.quote,
|
||||
'ranges': [{
|
||||
'start': self.range_start,
|
||||
'startOffset': self.range_start_offset,
|
||||
'end': self.range_end,
|
||||
'endOffset': self.range_end_offset
|
||||
}],
|
||||
'tags': self.tags.split(","),
|
||||
'created': str(self.created),
|
||||
'updated': str(self.updated)
|
||||
}
|
||||
398
lms/djangoapps/notes/tests.py
Normal file
398
lms/djangoapps/notes/tests.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Unit tests for the notes app.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import collections
|
||||
import unittest
|
||||
import json
|
||||
import logging
|
||||
|
||||
from . import utils, api, models
|
||||
|
||||
|
||||
class UtilsTest(TestCase):
|
||||
def setUp(self):
|
||||
'''
|
||||
Setup a dummy course-like object with a tabs field that can be
|
||||
accessed via attribute lookup.
|
||||
'''
|
||||
self.course = collections.namedtuple('DummyCourse', ['tabs'])
|
||||
self.course.tabs = []
|
||||
|
||||
def test_notes_not_enabled(self):
|
||||
'''
|
||||
Tests that notes are disabled when the course tab configuration does NOT
|
||||
contain a tab with type "notes."
|
||||
'''
|
||||
self.assertFalse(utils.notes_enabled_for_course(self.course))
|
||||
|
||||
def test_notes_enabled(self):
|
||||
'''
|
||||
Tests that notes are enabled when the course tab configuration contains
|
||||
a tab with type "notes."
|
||||
'''
|
||||
self.course.tabs = [{'type': 'foo'},
|
||||
{'name': 'My Notes', 'type': 'notes'},
|
||||
{'type': 'bar'}]
|
||||
|
||||
self.assertTrue(utils.notes_enabled_for_course(self.course))
|
||||
|
||||
|
||||
class ApiTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
|
||||
# Mocks
|
||||
api.api_enabled = self.mock_api_enabled(True)
|
||||
|
||||
# Create two accounts
|
||||
self.password = 'abc'
|
||||
self.student = User.objects.create_user('student', 'student@test.com', self.password)
|
||||
self.student2 = User.objects.create_user('student2', 'student2@test.com', self.password)
|
||||
self.instructor = User.objects.create_user('instructor', 'instructor@test.com', self.password)
|
||||
self.course_id = 'HarvardX/CB22x/The_Ancient_Greek_Hero'
|
||||
self.note = {
|
||||
'user': self.student,
|
||||
'course_id': self.course_id,
|
||||
'uri': '/',
|
||||
'text': 'foo',
|
||||
'quote': 'bar',
|
||||
'range_start': 0,
|
||||
'range_start_offset': 0,
|
||||
'range_end': 100,
|
||||
'range_end_offset': 0,
|
||||
'tags': 'a,b,c'
|
||||
}
|
||||
|
||||
# Make sure no note with this ID ever exists for testing purposes
|
||||
self.NOTE_ID_DOES_NOT_EXIST = 99999
|
||||
|
||||
def mock_api_enabled(self, is_enabled):
|
||||
return (lambda request, course_id: is_enabled)
|
||||
|
||||
def login(self, as_student=None):
|
||||
username = None
|
||||
password = self.password
|
||||
|
||||
if as_student is None:
|
||||
username = self.student.username
|
||||
else:
|
||||
username = as_student.username
|
||||
|
||||
self.client.login(username=username, password=password)
|
||||
|
||||
def url(self, name, args={}):
|
||||
args.update({'course_id': self.course_id})
|
||||
return reverse(name, kwargs=args)
|
||||
|
||||
def create_notes(self, num_notes, create=True):
|
||||
notes = []
|
||||
for n in range(num_notes):
|
||||
note = models.Note(**self.note)
|
||||
if create:
|
||||
note.save()
|
||||
notes.append(note)
|
||||
return notes
|
||||
|
||||
def test_root(self):
|
||||
self.login()
|
||||
|
||||
resp = self.client.get(self.url('notes_api_root'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotEqual(resp.content, '')
|
||||
|
||||
content = json.loads(resp.content)
|
||||
|
||||
self.assertEqual(set(('name', 'version')), set(content.keys()))
|
||||
self.assertIsInstance(content['version'], int)
|
||||
self.assertEqual(content['name'], 'Notes API')
|
||||
|
||||
def test_index_empty(self):
|
||||
self.login()
|
||||
|
||||
resp = self.client.get(self.url('notes_api_notes'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotEqual(resp.content, '')
|
||||
|
||||
content = json.loads(resp.content)
|
||||
self.assertEqual(len(content), 0)
|
||||
|
||||
def test_index_with_notes(self):
|
||||
num_notes = 3
|
||||
self.login()
|
||||
self.create_notes(num_notes)
|
||||
|
||||
resp = self.client.get(self.url('notes_api_notes'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotEqual(resp.content, '')
|
||||
|
||||
content = json.loads(resp.content)
|
||||
self.assertIsInstance(content, list)
|
||||
self.assertEqual(len(content), num_notes)
|
||||
|
||||
def test_index_max_notes(self):
|
||||
self.login()
|
||||
|
||||
MAX_LIMIT = api.API_SETTINGS.get('MAX_NOTE_LIMIT')
|
||||
num_notes = MAX_LIMIT + 1
|
||||
self.create_notes(num_notes)
|
||||
|
||||
resp = self.client.get(self.url('notes_api_notes'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotEqual(resp.content, '')
|
||||
|
||||
content = json.loads(resp.content)
|
||||
self.assertIsInstance(content, list)
|
||||
self.assertEqual(len(content), MAX_LIMIT)
|
||||
|
||||
def test_create_note(self):
|
||||
self.login()
|
||||
|
||||
notes = self.create_notes(1)
|
||||
self.assertEqual(len(notes), 1)
|
||||
|
||||
note_dict = notes[0].as_dict()
|
||||
excluded_fields = ['id', 'user_id', 'created', 'updated']
|
||||
note = dict([(k, v) for k, v in note_dict.items() if k not in excluded_fields])
|
||||
|
||||
resp = self.client.post(self.url('notes_api_notes'),
|
||||
json.dumps(note),
|
||||
content_type='application/json',
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
self.assertEqual(resp.status_code, 303)
|
||||
self.assertEqual(len(resp.content), 0)
|
||||
|
||||
def test_create_empty_notes(self):
|
||||
self.login()
|
||||
|
||||
for empty_test in [None, [], '']:
|
||||
resp = self.client.post(self.url('notes_api_notes'),
|
||||
json.dumps(empty_test),
|
||||
content_type='application/json',
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_create_note_missing_ranges(self):
|
||||
self.login()
|
||||
|
||||
notes = self.create_notes(1)
|
||||
self.assertEqual(len(notes), 1)
|
||||
note_dict = notes[0].as_dict()
|
||||
|
||||
excluded_fields = ['id', 'user_id', 'created', 'updated'] + ['ranges']
|
||||
note = dict([(k, v) for k, v in note_dict.items() if k not in excluded_fields])
|
||||
|
||||
resp = self.client.post(self.url('notes_api_notes'),
|
||||
json.dumps(note),
|
||||
content_type='application/json',
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_read_note(self):
|
||||
self.login()
|
||||
|
||||
notes = self.create_notes(3)
|
||||
self.assertEqual(len(notes), 3)
|
||||
|
||||
for note in notes:
|
||||
resp = self.client.get(self.url('notes_api_note', {'note_id': note.pk}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotEqual(resp.content, '')
|
||||
|
||||
content = json.loads(resp.content)
|
||||
self.assertEqual(content['id'], note.pk)
|
||||
self.assertEqual(content['user_id'], note.user_id)
|
||||
|
||||
def test_note_doesnt_exist_to_read(self):
|
||||
self.login()
|
||||
resp = self.client.get(self.url('notes_api_note', {
|
||||
'note_id': self.NOTE_ID_DOES_NOT_EXIST
|
||||
}))
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
self.assertEqual(resp.content, '')
|
||||
|
||||
def test_student_doesnt_have_permission_to_read_note(self):
|
||||
notes = self.create_notes(1)
|
||||
self.assertEqual(len(notes), 1)
|
||||
note = notes[0]
|
||||
|
||||
# set the student id to a different student (not the one that created the notes)
|
||||
self.login(as_student=self.student2)
|
||||
resp = self.client.get(self.url('notes_api_note', {'note_id': note.pk}))
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertEqual(resp.content, '')
|
||||
|
||||
def test_delete_note(self):
|
||||
self.login()
|
||||
|
||||
notes = self.create_notes(1)
|
||||
self.assertEqual(len(notes), 1)
|
||||
note = notes[0]
|
||||
|
||||
resp = self.client.delete(self.url('notes_api_note', {
|
||||
'note_id': note.pk
|
||||
}))
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
self.assertEqual(resp.content, '')
|
||||
|
||||
with self.assertRaises(models.Note.DoesNotExist):
|
||||
models.Note.objects.get(pk=note.pk)
|
||||
|
||||
def test_note_does_not_exist_to_delete(self):
|
||||
self.login()
|
||||
|
||||
resp = self.client.delete(self.url('notes_api_note', {
|
||||
'note_id': self.NOTE_ID_DOES_NOT_EXIST
|
||||
}))
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
self.assertEqual(resp.content, '')
|
||||
|
||||
def test_student_doesnt_have_permission_to_delete_note(self):
|
||||
notes = self.create_notes(1)
|
||||
self.assertEqual(len(notes), 1)
|
||||
note = notes[0]
|
||||
|
||||
self.login(as_student=self.student2)
|
||||
resp = self.client.delete(self.url('notes_api_note', {
|
||||
'note_id': note.pk
|
||||
}))
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertEqual(resp.content, '')
|
||||
|
||||
try:
|
||||
models.Note.objects.get(pk=note.pk)
|
||||
except models.Note.DoesNotExist:
|
||||
self.fail('note should exist and not be deleted because the student does not have permission to do so')
|
||||
|
||||
def test_update_note(self):
|
||||
notes = self.create_notes(1)
|
||||
note = notes[0]
|
||||
|
||||
updated_dict = note.as_dict()
|
||||
updated_dict.update({
|
||||
'text': 'itchy and scratchy',
|
||||
'tags': ['simpsons', 'cartoons', 'animation']
|
||||
})
|
||||
|
||||
self.login()
|
||||
resp = self.client.put(self.url('notes_api_note', {'note_id': note.pk}),
|
||||
json.dumps(updated_dict),
|
||||
content_type='application/json',
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(resp.status_code, 303)
|
||||
self.assertEqual(resp.content, '')
|
||||
|
||||
actual = models.Note.objects.get(pk=note.pk)
|
||||
actual_dict = actual.as_dict()
|
||||
for field in ['text', 'tags']:
|
||||
self.assertEqual(actual_dict[field], updated_dict[field])
|
||||
|
||||
def test_search_note_params(self):
|
||||
self.login()
|
||||
|
||||
total = 3
|
||||
notes = self.create_notes(total)
|
||||
invalid_uri = ''.join([note.uri for note in notes])
|
||||
|
||||
tests = [{'limit': 0, 'offset': 0, 'expected_rows': total},
|
||||
{'limit': 0, 'offset': 2, 'expected_rows': total - 2},
|
||||
{'limit': 0, 'offset': total, 'expected_rows': 0},
|
||||
{'limit': 1, 'offset': 0, 'expected_rows': 1},
|
||||
{'limit': 2, 'offset': 0, 'expected_rows': 2},
|
||||
{'limit': total, 'offset': 2, 'expected_rows': 1},
|
||||
{'limit': total, 'offset': total, 'expected_rows': 0},
|
||||
{'limit': total + 1, 'offset': total + 1, 'expected_rows': 0},
|
||||
{'limit': total + 1, 'offset': 0, 'expected_rows': total},
|
||||
{'limit': 0, 'offset': 0, 'uri': invalid_uri, 'expected_rows': 0, 'expected_total': 0}]
|
||||
|
||||
for test in tests:
|
||||
params = dict([(k, str(test[k]))
|
||||
for k in ('limit', 'offset', 'uri')
|
||||
if k in test])
|
||||
resp = self.client.get(self.url('notes_api_search'),
|
||||
params,
|
||||
content_type='application/json',
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotEqual(resp.content, '')
|
||||
|
||||
content = json.loads(resp.content)
|
||||
|
||||
for expected_key in ('total', 'rows'):
|
||||
self.assertTrue(expected_key in content)
|
||||
|
||||
if 'expected_total' in test:
|
||||
self.assertEqual(content['total'], test['expected_total'])
|
||||
else:
|
||||
self.assertEqual(content['total'], total)
|
||||
|
||||
self.assertEqual(len(content['rows']), test['expected_rows'])
|
||||
|
||||
for row in content['rows']:
|
||||
self.assertTrue('id' in row)
|
||||
|
||||
|
||||
class NoteTest(TestCase):
|
||||
def setUp(self):
|
||||
self.password = 'abc'
|
||||
self.student = User.objects.create_user('student', 'student@test.com', self.password)
|
||||
self.course_id = 'HarvardX/CB22x/The_Ancient_Greek_Hero'
|
||||
self.note = {
|
||||
'user': self.student,
|
||||
'course_id': self.course_id,
|
||||
'uri': '/',
|
||||
'text': 'foo',
|
||||
'quote': 'bar',
|
||||
'range_start': 0,
|
||||
'range_start_offset': 0,
|
||||
'range_end': 100,
|
||||
'range_end_offset': 0,
|
||||
'tags': 'a,b,c'
|
||||
}
|
||||
|
||||
def test_clean_valid_note(self):
|
||||
reference_note = models.Note(**self.note)
|
||||
body = reference_note.as_dict()
|
||||
|
||||
note = models.Note(course_id=self.course_id, user=self.student)
|
||||
try:
|
||||
note.clean(json.dumps(body))
|
||||
self.assertEqual(note.uri, body['uri'])
|
||||
self.assertEqual(note.text, body['text'])
|
||||
self.assertEqual(note.quote, body['quote'])
|
||||
self.assertEqual(note.range_start, body['ranges'][0]['start'])
|
||||
self.assertEqual(note.range_start_offset, body['ranges'][0]['startOffset'])
|
||||
self.assertEqual(note.range_end, body['ranges'][0]['end'])
|
||||
self.assertEqual(note.range_end_offset, body['ranges'][0]['endOffset'])
|
||||
self.assertEqual(note.tags, ','.join(body['tags']))
|
||||
except ValidationError:
|
||||
self.fail('a valid note should not raise an exception')
|
||||
|
||||
def test_clean_invalid_note(self):
|
||||
note = models.Note(course_id=self.course_id, user=self.student)
|
||||
for empty_type in (None, '', 0, []):
|
||||
with self.assertRaises(ValidationError):
|
||||
note.clean(None)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
note.clean(json.dumps({
|
||||
'text': 'foo',
|
||||
'quote': 'bar',
|
||||
'ranges': [{} for i in range(10)] # too many ranges
|
||||
}))
|
||||
|
||||
def test_as_dict(self):
|
||||
note = models.Note(course_id=self.course_id, user=self.student)
|
||||
d = note.as_dict()
|
||||
self.assertNotIsInstance(d, basestring)
|
||||
self.assertEqual(d['user_id'], self.student.id)
|
||||
self.assertTrue('course_id' not in d)
|
||||
10
lms/djangoapps/notes/urls.py
Normal file
10
lms/djangoapps/notes/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
|
||||
id_regex = r"(?P<note_id>[0-9A-Fa-f]+)"
|
||||
urlpatterns = patterns('notes.api',
|
||||
url(r'^api$', 'api_request', {'resource': 'root'}, name='notes_api_root'),
|
||||
url(r'^api/annotations$', 'api_request', {'resource': 'notes'}, name='notes_api_notes'),
|
||||
url(r'^api/annotations/' + id_regex + r'$', 'api_request', {'resource': 'note'}, name='notes_api_note'),
|
||||
url(r'^api/search', 'api_request', {'resource': 'search'}, name='notes_api_search')
|
||||
)
|
||||
17
lms/djangoapps/notes/utils.py
Normal file
17
lms/djangoapps/notes/utils.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def notes_enabled_for_course(course):
|
||||
|
||||
'''
|
||||
Returns True if the notes app is enabled for the course, False otherwise.
|
||||
|
||||
In order for the app to be enabled it must be:
|
||||
1) enabled globally via MITX_FEATURES.
|
||||
2) present in the course tab configuration.
|
||||
'''
|
||||
|
||||
tab_found = next((True for t in course.tabs if t['type'] == 'notes'), False)
|
||||
feature_enabled = settings.MITX_FEATURES.get('ENABLE_STUDENT_NOTES')
|
||||
|
||||
return feature_enabled and tab_found
|
||||
24
lms/djangoapps/notes/views.py
Normal file
24
lms/djangoapps/notes/views.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import Http404
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from courseware.courses import get_course_with_access
|
||||
from notes.models import Note
|
||||
from notes.utils import notes_enabled_for_course
|
||||
import json
|
||||
|
||||
|
||||
@login_required
|
||||
def notes(request, course_id):
|
||||
''' Displays the student's notes. '''
|
||||
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
if not notes_enabled_for_course(course):
|
||||
raise Http404
|
||||
|
||||
notes = Note.objects.filter(course_id=course_id, user=request.user).order_by('-created', 'uri')
|
||||
context = {
|
||||
'course': course,
|
||||
'notes': notes
|
||||
}
|
||||
|
||||
return render_to_response('notes.html', context)
|
||||
@@ -84,7 +84,9 @@ class TestStaffGradingService(LoginEnrollmentTestCase):
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
|
||||
d = json.loads(r.content)
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
self.assertEquals(d['submission_id'], self.mock_service.cnt)
|
||||
self.assertIsNotNone(d['submission'])
|
||||
@@ -130,6 +132,7 @@ class TestStaffGradingService(LoginEnrollmentTestCase):
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
|
||||
self.assertTrue(d['success'], str(d))
|
||||
self.assertIsNotNone(d['problem_list'])
|
||||
|
||||
@@ -179,7 +182,8 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.peer_module.get_next_submission(data)
|
||||
d = json.loads(r)
|
||||
d = r
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
self.assertIsNotNone(d['submission_id'])
|
||||
self.assertIsNotNone(d['prompt'])
|
||||
@@ -213,7 +217,8 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
|
||||
qdict.keys = data.keys
|
||||
|
||||
r = self.peer_module.save_grade(qdict)
|
||||
d = json.loads(r)
|
||||
d = r
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
|
||||
def test_save_grade_missing_keys(self):
|
||||
@@ -225,7 +230,8 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
|
||||
def test_is_calibrated_success(self):
|
||||
data = {'location': self.location}
|
||||
r = self.peer_module.is_student_calibrated(data)
|
||||
d = json.loads(r)
|
||||
d = r
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
self.assertTrue('calibrated' in d)
|
||||
|
||||
@@ -239,9 +245,8 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.peer_module.show_calibration_essay(data)
|
||||
d = json.loads(r)
|
||||
log.debug(d)
|
||||
log.debug(type(d))
|
||||
d = r
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
self.assertIsNotNone(d['submission_id'])
|
||||
self.assertIsNotNone(d['prompt'])
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import Http404
|
||||
from django.core.urlresolvers import reverse
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from courseware.access import has_access
|
||||
from courseware.courses import get_course_with_access
|
||||
from notes.utils import notes_enabled_for_course
|
||||
from static_replace import replace_static_urls
|
||||
|
||||
|
||||
@@ -23,7 +25,8 @@ def index(request, course_id, book_index, page=None):
|
||||
|
||||
return render_to_response('staticbook.html',
|
||||
{'book_index': book_index, 'page': int(page),
|
||||
'course': course, 'book_url': textbook.book_url,
|
||||
'course': course,
|
||||
'book_url': textbook.book_url,
|
||||
'table_of_contents': table_of_contents,
|
||||
'start_page': textbook.start_page,
|
||||
'end_page': textbook.end_page,
|
||||
@@ -100,6 +103,7 @@ def html_index(request, course_id, book_index, chapter=None):
|
||||
"""
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
staff_access = has_access(request.user, course, 'staff')
|
||||
notes_enabled = notes_enabled_for_course(course)
|
||||
|
||||
book_index = int(book_index)
|
||||
if book_index < 0 or book_index >= len(course.html_textbooks):
|
||||
@@ -128,4 +132,5 @@ def html_index(request, course_id, book_index, chapter=None):
|
||||
'course': course,
|
||||
'textbook': textbook,
|
||||
'chapter': chapter,
|
||||
'staff_access': staff_access})
|
||||
'staff_access': staff_access,
|
||||
'notes_enabled': notes_enabled})
|
||||
|
||||
@@ -8,13 +8,17 @@ from .test import *
|
||||
# otherwise the browser will not render the pages correctly
|
||||
DEBUG = True
|
||||
|
||||
# Disable warnings for acceptance tests, to make the logs readable
|
||||
import logging
|
||||
logging.disable(logging.ERROR)
|
||||
|
||||
# Use the mongo store for acceptance tests
|
||||
modulestore_options = {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'host': 'localhost',
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'modulestore',
|
||||
'fs_root': GITHUB_REPO_ROOT,
|
||||
'collection': 'acceptance_modulestore',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
|
||||
@@ -33,7 +37,7 @@ CONTENTSTORE = {
|
||||
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
|
||||
'OPTIONS': {
|
||||
'host': 'localhost',
|
||||
'db': 'test_xcontent',
|
||||
'db': 'test_xmodule',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +47,8 @@ CONTENTSTORE = {
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ENV_ROOT / "db" / "test_mitx.db",
|
||||
'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db",
|
||||
'NAME': TEST_ROOT / "db" / "test_mitx.db",
|
||||
'TEST_NAME': TEST_ROOT / "db" / "test_mitx.db",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ MITX_FEATURES = {
|
||||
# Staff Debug tool.
|
||||
'ENABLE_STUDENT_HISTORY_VIEW': True,
|
||||
|
||||
# Enables the student notes API and UI.
|
||||
'ENABLE_STUDENT_NOTES': True,
|
||||
|
||||
# Provide a UI to allow users to submit feedback from the LMS
|
||||
'ENABLE_FEEDBACK_SUBMISSION': False,
|
||||
}
|
||||
@@ -123,9 +126,7 @@ sys.path.append(COMMON_ROOT / 'lib')
|
||||
|
||||
# For Node.js
|
||||
|
||||
system_node_path = os.environ.get("NODE_PATH", None)
|
||||
if system_node_path is None:
|
||||
system_node_path = "/usr/local/lib/node_modules"
|
||||
system_node_path = os.environ.get("NODE_PATH", REPO_ROOT / 'node_modules')
|
||||
|
||||
node_paths = [COMMON_ROOT / "static/js/vendor",
|
||||
COMMON_ROOT / "static/coffee/src",
|
||||
@@ -424,11 +425,15 @@ main_vendor_js = [
|
||||
'js/vendor/jquery.qtip.min.js',
|
||||
'js/vendor/swfobject/swfobject.js',
|
||||
'js/vendor/jquery.ba-bbq.min.js',
|
||||
'js/vendor/annotator.min.js',
|
||||
'js/vendor/annotator.store.min.js',
|
||||
'js/vendor/annotator.tags.min.js'
|
||||
]
|
||||
|
||||
discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.js'))
|
||||
staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.js'))
|
||||
open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_ended/**/*.js'))
|
||||
notes_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/notes/**/*.coffee'))
|
||||
|
||||
PIPELINE_CSS = {
|
||||
'application': {
|
||||
@@ -441,6 +446,7 @@ PIPELINE_CSS = {
|
||||
'css/vendor/jquery.treeview.css',
|
||||
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
|
||||
'css/vendor/jquery.qtip.min.css',
|
||||
'css/vendor/annotator.min.css',
|
||||
'sass/course.css',
|
||||
'xmodule/modules.css',
|
||||
],
|
||||
@@ -462,7 +468,7 @@ PIPELINE_JS = {
|
||||
'source_filenames': sorted(
|
||||
set(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/**/*.js') +
|
||||
rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/**/*.js')) -
|
||||
set(courseware_js + discussion_js + staff_grading_js + open_ended_js)
|
||||
set(courseware_js + discussion_js + staff_grading_js + open_ended_js + notes_js)
|
||||
) + [
|
||||
'js/form.ext.js',
|
||||
'js/my_courses_dropdown.js',
|
||||
@@ -503,7 +509,12 @@ PIPELINE_JS = {
|
||||
'source_filenames': open_ended_js,
|
||||
'output_filename': 'js/open_ended.js',
|
||||
'test_order': 6,
|
||||
}
|
||||
},
|
||||
'notes': {
|
||||
'source_filenames': notes_js,
|
||||
'output_filename': 'js/notes.js',
|
||||
'test_order': 7
|
||||
},
|
||||
}
|
||||
|
||||
PIPELINE_DISABLE_WRAPPER = True
|
||||
@@ -593,5 +604,8 @@ INSTALLED_APPS = (
|
||||
|
||||
# Discussion forums
|
||||
'django_comment_client',
|
||||
|
||||
# Student notes
|
||||
'notes',
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,6 @@ PIPELINE_JS['spec'] = {
|
||||
|
||||
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
|
||||
|
||||
STATICFILES_DIRS.append(COMMON_ROOT / 'test' / 'phantom-jasmine' / 'lib')
|
||||
STATICFILES_DIRS.append(REPO_ROOT/'node_modules/phantom-jasmine/lib')
|
||||
|
||||
INSTALLED_APPS += ('django_jasmine', )
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from dogapi import dog_stats_api
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
@@ -30,12 +31,18 @@ def merge_dict(dic1, dic2):
|
||||
def perform_request(method, url, data_or_params=None, *args, **kwargs):
|
||||
if data_or_params is None:
|
||||
data_or_params = {}
|
||||
tags = [
|
||||
"{k}:{v}".format(k=k, v=v)
|
||||
for (k, v) in data_or_params.items() + [("method", method), ("url", url)]
|
||||
if k != 'api_key'
|
||||
]
|
||||
data_or_params['api_key'] = settings.API_KEY
|
||||
try:
|
||||
if method in ['post', 'put', 'patch']:
|
||||
response = requests.request(method, url, data=data_or_params, timeout=5)
|
||||
else:
|
||||
response = requests.request(method, url, params=data_or_params, timeout=5)
|
||||
with dog_stats_api.timer('comment_client.request.time', tags=tags):
|
||||
if method in ['post', 'put', 'patch']:
|
||||
response = requests.request(method, url, data=data_or_params, timeout=5)
|
||||
else:
|
||||
response = requests.request(method, url, params=data_or_params, timeout=5)
|
||||
except Exception as err:
|
||||
# remove API key if it is in the params
|
||||
if 'api_key' in data_or_params:
|
||||
|
||||
73
lms/static/coffee/src/notes.coffee
Normal file
73
lms/static/coffee/src/notes.coffee
Normal file
@@ -0,0 +1,73 @@
|
||||
class StudentNotes
|
||||
_debug: false
|
||||
|
||||
targets: [] # holds elements with annotator() instances
|
||||
|
||||
# Adds a listener for "notes" events that may bubble up from descendants.
|
||||
constructor: ($, el) ->
|
||||
console.log 'student notes init', arguments, this if @_debug
|
||||
|
||||
if not $(el).data('notes-instance')
|
||||
events = 'notes:init': @onInitNotes
|
||||
$(el).delegate('*', events)
|
||||
$(el).data('notes-instance', @)
|
||||
|
||||
# Initializes annotations on a container element in response to an init event.
|
||||
onInitNotes: (event, uri=null) =>
|
||||
event.stopPropagation()
|
||||
|
||||
storeConfig = @getStoreConfig uri
|
||||
found = @targets.some (target) -> target is event.target
|
||||
|
||||
if found
|
||||
annotator = $(event.target).data('annotator')
|
||||
if annotator
|
||||
store = annotator.plugins['Store']
|
||||
$.extend(store.options, storeConfig)
|
||||
if uri
|
||||
store.loadAnnotationsFromSearch(storeConfig['loadFromSearch'])
|
||||
else
|
||||
console.log 'URI is required to load annotations'
|
||||
else
|
||||
console.log 'No annotator() instance found for target: ', event.target
|
||||
else
|
||||
$(event.target).annotator()
|
||||
.annotator('addPlugin', 'Tags')
|
||||
.annotator('addPlugin', 'Store', storeConfig)
|
||||
@targets.push(event.target)
|
||||
|
||||
# Returns a JSON config object that can be passed to the annotator Store plugin
|
||||
getStoreConfig: (uri) ->
|
||||
prefix = @getPrefix()
|
||||
if uri is null
|
||||
uri = @getURIPath()
|
||||
|
||||
storeConfig =
|
||||
prefix: prefix
|
||||
loadFromSearch:
|
||||
uri: uri
|
||||
limit: 0
|
||||
annotationData:
|
||||
uri: uri
|
||||
storeConfig
|
||||
|
||||
# Returns the API endpoint for the annotation store
|
||||
getPrefix: () ->
|
||||
re = /^(\/courses\/[^/]+\/[^/]+\/[^/]+)/
|
||||
match = re.exec(@getURIPath())
|
||||
prefix = (if match then match[1] else '')
|
||||
return "#{prefix}/notes/api"
|
||||
|
||||
# Returns the URI path of the current page for filtering annotations
|
||||
getURIPath: () ->
|
||||
window.location.href.toString().split(window.location.host)[1]
|
||||
|
||||
|
||||
# Enable notes by default on the document root.
|
||||
# To initialize annotations on a container element in the document:
|
||||
#
|
||||
# $('#myElement').trigger('notes:init');
|
||||
#
|
||||
# Comment this line to disable notes.
|
||||
|
||||
$(document).ready ($) -> new StudentNotes $, @
|
||||
899
lms/static/css/vendor/annotator.css
vendored
Normal file
899
lms/static/css/vendor/annotator.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
lms/static/css/vendor/annotator.min.css
vendored
Normal file
1
lms/static/css/vendor/annotator.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
81
lms/templates/notes.html
Normal file
81
lms/templates/notes.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%inherit file="main.html" />
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='course'/>
|
||||
<%static:js group='courseware'/>
|
||||
<style type="text/css">
|
||||
blockquote {
|
||||
background:#f9f9f9;
|
||||
border-left:10px solid #ccc;
|
||||
margin:1.5em 10px;
|
||||
padding:.5em 10px;
|
||||
}
|
||||
blockquote:before {
|
||||
color:#ccc;
|
||||
content:'“';
|
||||
font-size:4em;
|
||||
line-height:.1em;
|
||||
margin-right:.25em;
|
||||
vertical-align:-.4em;
|
||||
}
|
||||
blockquote p {
|
||||
display:inline;
|
||||
}
|
||||
.notes-wrapper {
|
||||
padding: 32px 40px;
|
||||
}
|
||||
.note {
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding: 0 0 1em 0;
|
||||
}
|
||||
.note .text {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.note ul.meta {
|
||||
margin: .5em 0;
|
||||
}
|
||||
.note ul.meta li {
|
||||
font-size: .9em;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
</%block>
|
||||
|
||||
<%block name="js_extra">
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='notes'" />
|
||||
|
||||
<section class="container">
|
||||
<div class="notes-wrapper">
|
||||
<h1>My Notes</h1>
|
||||
% for note in notes:
|
||||
<div class="note">
|
||||
<blockquote>${note.quote|h}</blockquote>
|
||||
<div class="text">${note.text.replace("\n", "<br />") | n,h}</div>
|
||||
<ul class="meta">
|
||||
% if note.tags:
|
||||
<li class="tags">Tags: ${note.tags|h}</li>
|
||||
% endif
|
||||
<li class="user">Author: ${note.user.username}</li>
|
||||
<li class="time">Created: ${note.created.strftime('%m/%d/%Y %H:%m')}</li>
|
||||
<li class="uri">Source: <a href="${note.uri}">${note.uri|h}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
% endfor
|
||||
% if notes is UNDEFINED or len(notes) == 0:
|
||||
<p>You do not have any notes.</p>
|
||||
% endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -26,22 +26,41 @@
|
||||
// chapters, and it should be in-bounds.
|
||||
chapterToLoad = options.chapterNum;
|
||||
}
|
||||
var anchorToLoad = null;
|
||||
if (options.chapters) {
|
||||
anchorToLoad = options.anchor_id;
|
||||
}
|
||||
|
||||
loadUrl = function htmlViewLoadUrl(url) {
|
||||
var onComplete = function() {};
|
||||
if(options.notesEnabled) {
|
||||
onComplete = function(url) {
|
||||
return function() {
|
||||
$('#viewerContainer').trigger('notes:init', [url]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
loadUrl = function htmlViewLoadUrl(url, anchorId) {
|
||||
// clear out previous load, if any:
|
||||
parentElement = document.getElementById('bookpage');
|
||||
while (parentElement.hasChildNodes())
|
||||
parentElement.removeChild(parentElement.lastChild);
|
||||
// load new URL in:
|
||||
$('#bookpage').load(url);
|
||||
};
|
||||
$('#bookpage').load(url, null, onComplete(url));
|
||||
|
||||
loadChapterUrl = function htmlViewLoadChapterUrl(chapterNum) {
|
||||
// if there is an anchor set, then go to that location:
|
||||
if (anchorId != null) {
|
||||
// TODO: add implementation....
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
loadChapterUrl = function htmlViewLoadChapterUrl(chapterNum, anchorId) {
|
||||
if (chapterNum < 1 || chapterNum > chapterUrls.length) {
|
||||
return;
|
||||
}
|
||||
var chapterUrl = chapterUrls[chapterNum-1];
|
||||
loadUrl(chapterUrl);
|
||||
loadUrl(chapterUrl, anchorId);
|
||||
};
|
||||
|
||||
// define navigation links for chapters:
|
||||
@@ -54,15 +73,15 @@
|
||||
};
|
||||
for (var index = 1; index <= chapterUrls.length; index += 1) {
|
||||
$("#htmlchapter-" + index).click(loadChapterUrlHelper(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finally, load the appropriate url/page
|
||||
if (urlToLoad != null) {
|
||||
loadUrl(urlToLoad);
|
||||
loadUrl(urlToLoad, anchorToLoad);
|
||||
} else {
|
||||
loadChapterUrl(chapterToLoad);
|
||||
}
|
||||
loadChapterUrl(chapterToLoad, anchorToLoad);
|
||||
}
|
||||
|
||||
}
|
||||
})(jQuery);
|
||||
@@ -82,6 +101,14 @@
|
||||
%if chapter is not None:
|
||||
options.chapterNum = ${chapter};
|
||||
%endif
|
||||
%if anchor_id is not UNDEFINED and anchor_id is not None:
|
||||
options.anchor_id = ${anchor_id};
|
||||
%endif
|
||||
|
||||
options.notesEnabled = false;
|
||||
%if notes_enabled is not UNDEFINED and notes_enabled:
|
||||
options.notesEnabled = true;
|
||||
%endif
|
||||
|
||||
$('#outerContainer').myHTMLViewer(options);
|
||||
});
|
||||
|
||||
28
lms/templates/word_cloud.html
Normal file
28
lms/templates/word_cloud.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<section
|
||||
id="word_cloud_${element_id}"
|
||||
class="${element_class}"
|
||||
data-ajax-url="${ajax_url}"
|
||||
>
|
||||
|
||||
<section class="input_cloud_section">
|
||||
% for row in range(num_inputs):
|
||||
<input
|
||||
class="input-cloud"
|
||||
${'style="display: none;"' if submitted else ''}
|
||||
type="text"
|
||||
size="40"
|
||||
/>
|
||||
% endfor
|
||||
|
||||
<section class="action">
|
||||
<input class="save" type="button" value="Save" />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="result_cloud_section_${element_id}" class="result_cloud_section">
|
||||
<h3>Your words: <span class="your_words"></span></h3>
|
||||
<h3>Total number of words: <span class="total_num_words"></span></h3>
|
||||
<div class="word_cloud"></div>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
12
lms/urls.py
12
lms/urls.py
@@ -61,10 +61,12 @@ urlpatterns = ('', # nopep8
|
||||
|
||||
url(r'^heartbeat$', include('heartbeat.urls')),
|
||||
|
||||
##
|
||||
## Only universities without courses should be included here. If
|
||||
## courses exist, the dynamic profile rule below should win.
|
||||
##
|
||||
url(r'^(?i)university_profile/WellesleyX$', 'courseware.views.static_university_profile',
|
||||
name="static_university_profile", kwargs={'org_id': 'WellesleyX'}),
|
||||
url(r'^(?i)university_profile/GeorgetownX$', 'courseware.views.static_university_profile',
|
||||
name="static_university_profile", kwargs={'org_id': 'GeorgetownX'}),
|
||||
url(r'^(?i)university_profile/McGillX$', 'courseware.views.static_university_profile',
|
||||
name="static_university_profile", kwargs={'org_id': 'McGillX'}),
|
||||
url(r'^(?i)university_profile/TorontoX$', 'courseware.views.static_university_profile',
|
||||
@@ -73,8 +75,6 @@ urlpatterns = ('', # nopep8
|
||||
name="static_university_profile", kwargs={'org_id': 'RiceX'}),
|
||||
url(r'^(?i)university_profile/ANUx$', 'courseware.views.static_university_profile',
|
||||
name="static_university_profile", kwargs={'org_id': 'ANUx'}),
|
||||
url(r'^(?i)university_profile/DelftX$', 'courseware.views.static_university_profile',
|
||||
name="static_university_profile", kwargs={'org_id': 'DelftX'}),
|
||||
url(r'^(?i)university_profile/EPFLx$', 'courseware.views.static_university_profile',
|
||||
name="static_university_profile", kwargs={'org_id': 'EPFLx'}),
|
||||
|
||||
@@ -283,6 +283,10 @@ if settings.COURSEWARE_ENABLED:
|
||||
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading$',
|
||||
'open_ended_grading.views.peer_grading', name='peer_grading'),
|
||||
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/notes$', 'notes.views.notes', name='notes'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/notes/', include('notes.urls')),
|
||||
|
||||
)
|
||||
|
||||
# allow course staff to change to student view of courseware
|
||||
|
||||
Reference in New Issue
Block a user