Commit 77ae9a6a by David Baumgold

PDF Textbooks: fetch/save individual textbooks

Created a few RESTful API endpoints, which required creating and assigning
arbitrary IDs to PDF textbooks. Changed the Backbone views to save individual
models, instead of saving a whole collection.
parent 27e89539
...@@ -2,10 +2,13 @@ ...@@ -2,10 +2,13 @@
Views related to operations on course objects Views related to operations on course objects
""" """
import json import json
import random
import string
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.conf import settings from django.conf import settings
from django.views.decorators.http import require_http_methods
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseBadRequest
...@@ -48,7 +51,8 @@ __all__ = ['course_index', 'create_new_course', 'course_info', ...@@ -48,7 +51,8 @@ __all__ = ['course_index', 'create_new_course', 'course_info',
'course_config_advanced_page', 'course_config_advanced_page',
'course_settings_updates', 'course_settings_updates',
'course_grader_updates', 'course_grader_updates',
'course_advanced_updates', 'textbook_index'] 'course_advanced_updates', 'textbook_index', 'textbook_by_id',
'create_textbook']
@login_required @login_required
...@@ -421,17 +425,48 @@ class TextbookValidationError(Exception): ...@@ -421,17 +425,48 @@ class TextbookValidationError(Exception):
pass pass
def validate_textbook_json(text): def validate_textbooks_json(text):
try: try:
obj = json.loads(text) textbooks = json.loads(text)
except ValueError: except ValueError:
raise TextbookValidationError("invalid JSON") raise TextbookValidationError("invalid JSON")
if not isinstance(obj, (list, tuple)): if not isinstance(textbooks, (list, tuple)):
raise TextbookValidationError("must be JSON list") raise TextbookValidationError("must be JSON list")
for textbook in obj: for textbook in textbooks:
if not textbook.get("tab_title"): validate_textbook_json(textbook)
raise TextbookValidationError("every textbook must have a tab_title") # check specified IDs for uniqueness
return obj all_ids = [textbook["id"] for textbook in textbooks if "id" in textbook]
unique_ids = set(all_ids)
if len(all_ids) > len(unique_ids):
raise TextbookValidationError("IDs must be unique")
return textbooks
def validate_textbook_json(textbook, used_ids=()):
if isinstance(textbook, basestring):
try:
textbook = json.loads(textbook)
except ValueError:
raise TextbookValidationError("invalid JSON")
if not isinstance(textbook, dict):
raise TextbookValidationError("must be JSON object")
if not textbook.get("tab_title"):
raise TextbookValidationError("must have tab_title")
tid = str(textbook.get("id", ""))
if tid and not tid[0].isdigit():
raise TextbookValidationError("textbook ID must start with a digit")
return textbook
def assign_textbook_id(textbook, used_ids=()):
tid = Location.clean(textbook["tab_title"])
if not tid[0].isdigit():
# stick a random digit in front
tid = random.choice(string.digits) + tid
while tid in used_ids:
# add a random ASCII character to the end
tid = tid + random.choice(string.ascii_lowercase)
return tid
@login_required @login_required
...@@ -451,13 +486,22 @@ def textbook_index(request, org, course, name): ...@@ -451,13 +486,22 @@ def textbook_index(request, org, course, name):
return JsonResponse(course_module.pdf_textbooks) return JsonResponse(course_module.pdf_textbooks)
elif request.method == 'POST': elif request.method == 'POST':
try: try:
course_module.pdf_textbooks = validate_textbook_json(request.body) textbooks = validate_textbooks_json(request.body)
except TextbookValidationError as e: except TextbookValidationError as e:
return JsonResponse({"error": e.message}, status=400) return JsonResponse({"error": e.message}, status=400)
tids = set(t["id"] for t in textbooks if "id" in t)
for textbook in textbooks:
if not "id" in textbook:
tid = assign_textbook_id(textbook, tids)
textbook["id"] = tid
tids.add(tid)
if not any(tab['type'] == 'pdf_textbooks' for tab in course_module.tabs): if not any(tab['type'] == 'pdf_textbooks' for tab in course_module.tabs):
course_module.tabs.append({"type": "pdf_textbooks"}) course_module.tabs.append({"type": "pdf_textbooks"})
course_module.pdf_textbooks = textbooks
store.update_metadata(course_module.location, own_metadata(course_module)) store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse('', status=204) return JsonResponse(course_module.pdf_textbooks)
else: else:
upload_asset_url = reverse('upload_asset', kwargs={ upload_asset_url = reverse('upload_asset', kwargs={
'org': org, 'org': org,
...@@ -475,3 +519,74 @@ def textbook_index(request, org, course, name): ...@@ -475,3 +519,74 @@ def textbook_index(request, org, course, name):
'upload_asset_url': upload_asset_url, 'upload_asset_url': upload_asset_url,
'textbook_url': textbook_url, 'textbook_url': textbook_url,
}) })
@login_required
@ensure_csrf_cookie
@require_http_methods(("POST",))
def create_textbook(request, org, course, name):
location = get_location_and_verify_access(request, org, course, name)
store = get_modulestore(location)
course_module = store.get_item(location, depth=3)
try:
textbook = validate_textbook_json(request.body)
except TextbookValidationError:
return JsonResponse({"error": e.message}, status=400)
if not textbook.get("id"):
tids = set(t["id"] for t in course_module.pdf_textbooks if "id" in t)
textbook["id"] = assign_textbook_id(textbook, tids)
course_module.pdf_textbooks.append(textbook)
store.update_metadata(course_module.location, own_metadata(course_module))
resp = JsonResponse(textbook, status=201)
resp["Location"] = reverse("textbook_by_id", kwargs={
'org': org,
'course': course,
'name': name,
'tid': textbook["id"],
})
return resp
@login_required
@ensure_csrf_cookie
@require_http_methods(("GET", "POST", "DELETE"))
def textbook_by_id(request, org, course, name, tid):
location = get_location_and_verify_access(request, org, course, name)
store = get_modulestore(location)
course_module = store.get_item(location, depth=3)
matching_id = [tb for tb in course_module.pdf_textbooks if tb.get("id") == tid]
if matching_id:
textbook = matching_id[0]
else:
textbook = None
if request.method == 'GET':
if not textbook:
return JsonResponse(status=404)
return JsonResponse(textbook)
elif request.method == 'POST':
try:
new_textbook = validate_textbook_json(request.body)
except TextbookValidationError:
return JsonResponse({"error": e.message}, status=400)
new_textbook["id"] = tid
if textbook:
i = course_module.pdf_textbooks.index(textbook)
new_textbooks = course_module.pdf_textbooks[0:i]
new_textbooks.append(new_textbook)
new_textbooks.extend(course_module.pdf_textbooks[i+1:])
course_module.pdf_textbooks = new_textbooks
else:
course_module.pdf_textbooks.append(new_textbook)
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse(new_textbook, status=201)
elif request.method == 'DELETE':
if not textbook:
return JsonResponse(status=404)
i = course_module.pdf_textbooks.index(textbook)
new_textbooks = course_module.pdf_textbooks[0:i]
new_textbooks.extend(course_module.pdf_textbooks[i+1:])
course_module.pdf_textbooks = new_textbooks
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse(new_textbook)
...@@ -23,6 +23,9 @@ describe "CMS.Models.Textbook", -> ...@@ -23,6 +23,9 @@ describe "CMS.Models.Textbook", ->
it "should be empty by default", -> it "should be empty by default", ->
expect(@model.isEmpty()).toBeTruthy() expect(@model.isEmpty()).toBeTruthy()
it "should have a URL set", ->
expect(_.result(@model, "url")).toBeTruthy()
describe "CMS.Models.Textbook input/output", -> describe "CMS.Models.Textbook input/output", ->
# replace with Backbone.Assocations.deepAttributes when # replace with Backbone.Assocations.deepAttributes when
......
...@@ -76,8 +76,8 @@ describe "CMS.Views.EditTextbook", -> ...@@ -76,8 +76,8 @@ describe "CMS.Views.EditTextbook", ->
appendSetFixtures(sandbox({id: "page-notification"})) appendSetFixtures(sandbox({id: "page-notification"}))
appendSetFixtures(sandbox({id: "page-prompt"})) appendSetFixtures(sandbox({id: "page-prompt"}))
@model = new CMS.Models.Textbook({name: "Life Sciences", editing: true}) @model = new CMS.Models.Textbook({name: "Life Sciences", editing: true})
spyOn(@model, 'save')
@collection = new CMS.Collections.TextbookSet() @collection = new CMS.Collections.TextbookSet()
spyOn(@collection, 'save')
@collection.add(@model) @collection.add(@model)
@view = new CMS.Views.EditTextbook({model: @model}) @view = new CMS.Views.EditTextbook({model: @model})
spyOn(@view, 'render').andCallThrough() spyOn(@view, 'render').andCallThrough()
...@@ -100,7 +100,7 @@ describe "CMS.Views.EditTextbook", -> ...@@ -100,7 +100,7 @@ describe "CMS.Views.EditTextbook", ->
@view.$("form").submit() @view.$("form").submit()
expect(@model.get("name")).toEqual("starfish") expect(@model.get("name")).toEqual("starfish")
expect(@model.get("chapters").at(0).get("name")).toEqual("foobar") expect(@model.get("chapters").at(0).get("name")).toEqual("foobar")
expect(@collection.save).toHaveBeenCalled() expect(@model.save).toHaveBeenCalled()
it "does not save on cancel", -> it "does not save on cancel", ->
@model.get("chapters").add([{name: "a", asset_path: "b"}]) @model.get("chapters").add([{name: "a", asset_path: "b"}])
...@@ -110,7 +110,7 @@ describe "CMS.Views.EditTextbook", -> ...@@ -110,7 +110,7 @@ describe "CMS.Views.EditTextbook", ->
@view.$(".action-cancel").click() @view.$(".action-cancel").click()
expect(@model.get("name")).not.toEqual("starfish") expect(@model.get("name")).not.toEqual("starfish")
expect(@model.get("chapters").at(0).get("name")).not.toEqual("foobar") expect(@model.get("chapters").at(0).get("name")).not.toEqual("foobar")
expect(@collection.save).not.toHaveBeenCalled() expect(@model.save).not.toHaveBeenCalled()
it "removes all empty chapters on cancel if the model has a non-empty chapter", -> it "removes all empty chapters on cancel if the model has a non-empty chapter", ->
chapters = @model.get("chapters") chapters = @model.get("chapters")
......
...@@ -16,6 +16,13 @@ CMS.Models.Textbook = Backbone.AssociatedModel.extend({ ...@@ -16,6 +16,13 @@ CMS.Models.Textbook = Backbone.AssociatedModel.extend({
isEmpty: function() { isEmpty: function() {
return !this.get('name') && this.get('chapters').isEmpty(); return !this.get('name') && this.get('chapters').isEmpty();
}, },
url: function() {
if(this.isNew()) {
return CMS.URL.TEXTBOOK + "/new";
} else {
return CMS.URL.TEXTBOOK + "/" + this.id;
}
},
parse: function(response) { parse: function(response) {
var ret = $.extend(true, {}, response); var ret = $.extend(true, {}, response);
if("tab_title" in ret && !("name" in ret)) { if("tab_title" in ret && !("name" in ret)) {
......
...@@ -123,7 +123,7 @@ CMS.Views.EditTextbook = Backbone.View.extend({ ...@@ -123,7 +123,7 @@ CMS.Views.EditTextbook = Backbone.View.extend({
title: gettext("Saving…") title: gettext("Saving…")
}); });
var that = this; var that = this;
this.model.collection.save({ this.model.save({}, {
success: function() { success: function() {
that.close(); that.close();
}, },
......
...@@ -83,6 +83,10 @@ urlpatterns = ('', # nopep8 ...@@ -83,6 +83,10 @@ urlpatterns = ('', # nopep8
'contentstore.views.assets.remove_asset', name='remove_asset'), 'contentstore.views.assets.remove_asset', name='remove_asset'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)$',
'contentstore.views.textbook_index', name='textbook_index'), 'contentstore.views.textbook_index', name='textbook_index'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/new$',
'contentstore.views.create_textbook', name='create_textbook'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/(?P<tid>\d[^/]*)$',
'contentstore.views.textbook_by_id', name='textbook_by_id'),
# this is a generic method to return the data/metadata associated with a xmodule # this is a generic method to return the data/metadata associated with a xmodule
url(r'^module_info/(?P<module_location>.*)$', url(r'^module_info/(?P<module_location>.*)$',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment