Merge branch 'master' into ux/marco/forums-icons

This commit is contained in:
marco
2013-05-22 12:59:19 -04:00
231 changed files with 11272 additions and 3982 deletions

View File

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

View File

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

View 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

View File

251
lms/djangoapps/notes/api.py Normal file
View 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'))

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

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

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

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

View 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

81
lms/templates/notes.html Normal file
View 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>

View File

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

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

View File

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