Commit c20f3dbc by Don Mitchell

Merge pull request #1569 from edx/dhm/restful_course

restful course_info access
parents ce6ac653 99e7daf7
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore
from lxml import html, etree
import re import re
from django.http import HttpResponseBadRequest
import logging import logging
from lxml import html, etree
from django.http import HttpResponseBadRequest
import django.utils import django.utils
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore
# # TODO store as array of { date, content } and override course_info_module.definition_from_xml # # TODO store as array of { date, content } and override course_info_module.definition_from_xml
# # This should be in a class which inherits from XmlDescriptor # # This should be in a class which inherits from XmlDescriptor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def get_course_updates(location): def get_course_updates(location, provided_id):
""" """
Retrieve the relevant course_info updates and unpack into the model which the client expects: Retrieve the relevant course_info updates and unpack into the model which the client expects:
[{id : location.url() + idx to make unique, date : string, content : html string}] [{id : index, date : string, content : html string}]
""" """
try: try:
course_updates = modulestore('direct').get_item(location) course_updates = modulestore('direct').get_item(location)
...@@ -35,15 +35,23 @@ def get_course_updates(location): ...@@ -35,15 +35,23 @@ def get_course_updates(location):
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val # Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
course_upd_collection = [] course_upd_collection = []
provided_id = get_idx(provided_id) if provided_id is not None else None
if course_html_parsed.tag == 'ol': if course_html_parsed.tag == 'ol':
# 0 is the newest # 0 is the newest
for idx, update in enumerate(course_html_parsed): for idx, update in enumerate(course_html_parsed):
if len(update) > 0: if len(update) > 0:
content = _course_info_content(update) content = _course_info_content(update)
# make the id on the client be 1..len w/ 1 being the oldest and len being the newest # make the id on the client be 1..len w/ 1 being the oldest and len being the newest
course_upd_collection.append({"id": location_base + "/" + str(len(course_html_parsed) - idx), computed_id = len(course_html_parsed) - idx
"date": update.findtext("h2"), payload = {
"content": content}) "id": computed_id,
"date": update.findtext("h2"),
"content": content
}
if provided_id is None:
course_upd_collection.append(payload)
elif provided_id == computed_id:
return payload
return course_upd_collection return course_upd_collection
...@@ -57,7 +65,8 @@ def update_course_updates(location, update, passed_id=None): ...@@ -57,7 +65,8 @@ def update_course_updates(location, update, passed_id=None):
try: try:
course_updates = modulestore('direct').get_item(location) course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError: except ItemNotFoundError:
return HttpResponseBadRequest() modulestore('direct').create_and_save_xmodule(location)
course_updates = modulestore('direct').get_item(location)
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try: try:
...@@ -89,17 +98,17 @@ def update_course_updates(location, update, passed_id=None): ...@@ -89,17 +98,17 @@ def update_course_updates(location, update, passed_id=None):
course_html_parsed[-idx] = new_html_parsed course_html_parsed[-idx] = new_html_parsed
else: else:
course_html_parsed.insert(0, new_html_parsed) course_html_parsed.insert(0, new_html_parsed)
idx = len(course_html_parsed) idx = len(course_html_parsed)
passed_id = course_updates.location.url() + "/" + str(idx)
# update db record # update db record
course_updates.data = html.tostring(course_html_parsed) course_updates.data = html.tostring(course_html_parsed)
modulestore('direct').update_item(location, course_updates.data) modulestore('direct').update_item(location, course_updates.data)
return {"id": passed_id, return {
"date": update['date'], "id": idx,
"content": _course_info_content(new_html_parsed)} "date": update['date'],
"content": _course_info_content(new_html_parsed),
}
def _course_info_content(html_parsed): def _course_info_content(html_parsed):
...@@ -115,6 +124,7 @@ def _course_info_content(html_parsed): ...@@ -115,6 +124,7 @@ def _course_info_content(html_parsed):
return content return content
# pylint: disable=unused-argument
def delete_course_update(location, update, passed_id): def delete_course_update(location, update, passed_id):
""" """
Delete the given course_info update from the db. Delete the given course_info update from the db.
...@@ -150,7 +160,7 @@ def delete_course_update(location, update, passed_id): ...@@ -150,7 +160,7 @@ def delete_course_update(location, update, passed_id):
store = modulestore('direct') store = modulestore('direct')
store.update_item(location, course_updates.data) store.update_item(location, course_updates.data)
return get_course_updates(location) return get_course_updates(location, None)
def get_idx(passed_id): def get_idx(passed_id):
...@@ -160,3 +170,5 @@ def get_idx(passed_id): ...@@ -160,3 +170,5 @@ def get_idx(passed_id):
idx_matcher = re.search(r'.*?/?(\d+)$', passed_id) idx_matcher = re.search(r'.*?/?(\d+)$', passed_id)
if idx_matcher: if idx_matcher:
return int(idx_matcher.group(1)) return int(idx_matcher.group(1))
else:
return None
from static_replace import replace_static_urls
from xmodule.modulestore.exceptions import ItemNotFoundError
def get_module_info(store, location, rewrite_static_links=False):
try:
module = store.get_item(location)
except ItemNotFoundError:
# create a new one
store.create_and_save_xmodule(location)
module = store.get_item(location)
data = module.data
if rewrite_static_links:
# we pass a partially bogus course_id as we don't have the RUN information passed yet
# through the CMS. Also the contentstore is also not RUN-aware at this point in time.
data = replace_static_urls(
module.data,
None,
course_id=module.location.org + '/' + module.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
)
return {
'id': module.location.url(),
'data': data,
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
# what's the intent here? all metadata incl inherited & namespaced?
'metadata': module.xblock_kvs._metadata
}
def set_module_info(store, location, post_data):
module = None
try:
module = store.get_item(location)
except ItemNotFoundError:
# new module at this location: almost always used for the course about pages; thus, no parent. (there
# are quite a handful of about page types available for a course and only the overview is pre-created)
store.create_and_save_xmodule(location)
module = store.get_item(location)
if post_data.get('data') is not None:
data = post_data['data']
store.update_item(location, data)
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
# deleting the children object from the children collection
if 'children' in post_data and post_data['children'] is not None:
children = post_data['children']
store.update_children(location, children)
# cdodge: also commit any metadata which might have been passed along in the
# POST from the client, if it is there
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
# not presented to the end-user for editing. So let's fetch the original and
# 'apply' the submitted metadata, so we don't end up deleting system metadata
if post_data.get('metadata') is not None:
posted_metadata = post_data['metadata']
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
for metadata_key, value in posted_metadata.items():
if posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if module._field_data.has(module, metadata_key):
module._field_data.delete(module, metadata_key)
del posted_metadata[metadata_key]
else:
module._field_data.set(module, metadata_key, value)
# commit to datastore
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
store.update_metadata(location, module.xblock_kvs._metadata)
...@@ -1200,9 +1200,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -1200,9 +1200,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
import_from_xml(module_store, 'common/test/data/', ['toy']) import_from_xml(module_store, 'common/test/data/', ['toy'])
handout_location = Location(['i4x', 'edX', 'toy', 'course_info', 'handouts']) handout_location = Location(['i4x', 'edX', 'toy', 'course_info', 'handouts'])
# get the translation
handouts_locator = loc_mapper().translate_location('edX/toy/2012_Fall', handout_location)
# get module info # get module info (json)
resp = self.client.get_html(reverse('module_info', kwargs={'module_location': handout_location})) resp = self.client.get(handouts_locator.url_reverse('/xblock', ''))
# make sure we got a successful response # make sure we got a successful response
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
...@@ -1600,10 +1602,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -1600,10 +1602,7 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
# course info # course info
resp = self.client.get(reverse('course_info', resp = self.client.get(new_location.url_reverse('course_info'))
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
# settings_details # settings_details
...@@ -1627,14 +1626,15 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -1627,14 +1626,15 @@ class ContentStoreTest(ModuleStoreTestCase):
# go look at a subsection page # go look at a subsection page
subsection_location = loc.replace(category='sequential', name='test_sequence') subsection_location = loc.replace(category='sequential', name='test_sequence')
resp = self.client.get_html(reverse('edit_subsection', resp = self.client.get_html(
kwargs={'location': subsection_location.url()})) reverse('edit_subsection', kwargs={'location': subsection_location.url()})
)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
# go look at the Edit page # go look at the Edit page
unit_location = loc.replace(category='vertical', name='test_vertical') unit_location = loc.replace(category='vertical', name='test_vertical')
resp = self.client.get_html(reverse('edit_unit', resp = self.client.get_html(
kwargs={'location': unit_location.url()})) reverse('edit_unit', kwargs={'location': unit_location.url()}))
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
def delete_item(category, name): def delete_item(category, name):
......
'''unit tests for course_info views and models.''' '''unit tests for course_info views and models.'''
from contentstore.tests.test_course_settings import CourseTestCase from contentstore.tests.test_course_settings import CourseTestCase
from django.core.urlresolvers import reverse
import json import json
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore, loc_mapper
class CourseUpdateTest(CourseTestCase): class CourseUpdateTest(CourseTestCase):
...@@ -15,61 +14,61 @@ class CourseUpdateTest(CourseTestCase): ...@@ -15,61 +14,61 @@ class CourseUpdateTest(CourseTestCase):
Does not supply a provided_id. Does not supply a provided_id.
""" """
payload = {'content': content, payload = {'content': content, 'date': date}
'date': date} url = update_locator.url_reverse('course_info_update/')
url = reverse('course_info_json',
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.ajax_post(url, payload) resp = self.client.ajax_post(url, payload)
self.assertContains(resp, '', status_code=200)
return json.loads(resp.content) return json.loads(resp.content)
# first get the update to force the creation course_locator = loc_mapper().translate_location(
url = reverse('course_info', self.course.location.course_id, self.course.location, False, True
kwargs={'org': self.course.location.org, )
'course': self.course.location.course, resp = self.client.get_html(course_locator.url_reverse('course_info/'))
'name': self.course.location.name}) self.assertContains(resp, 'Course Updates', status_code=200)
self.client.get(url) update_locator = loc_mapper().translate_location(
self.course.location.course_id, self.course.location.replace(category='course_info', name='updates'),
False, True
)
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">' init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
content = init_content + '</iframe>' content = init_content + '</iframe>'
payload = get_response(content, 'January 8, 2013') payload = get_response(content, 'January 8, 2013')
self.assertHTMLEqual(payload['content'], content) self.assertHTMLEqual(payload['content'], content)
first_update_url = reverse('course_info_json', first_update_url = update_locator.url_reverse('course_info_update', str(payload['id']))
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': payload['id']})
content += '<div>div <p>p<br/></p></div>' content += '<div>div <p>p<br/></p></div>'
payload['content'] = content payload['content'] = content
# POST requests were coming in w/ these header values causing an error; so, repro error here # POST requests were coming in w/ these header values causing an error; so, repro error here
resp = self.client.post(first_update_url, json.dumps(payload), resp = self.client.ajax_post(
"application/json", first_update_url, payload, HTTP_X_HTTP_METHOD_OVERRIDE="PUT", REQUEST_METHOD="POST"
HTTP_X_HTTP_METHOD_OVERRIDE="PUT", )
REQUEST_METHOD="POST")
self.assertHTMLEqual(content, json.loads(resp.content)['content'], self.assertHTMLEqual(content, json.loads(resp.content)['content'],
"iframe w/ div") "iframe w/ div")
# refetch using provided id
refetched = self.client.get_json(first_update_url)
self.assertHTMLEqual(
content, json.loads(refetched.content)['content'], "get w/ provided id"
)
# now put in an evil update # now put in an evil update
content = '<ol/>' content = '<ol/>'
payload = get_response(content, 'January 11, 2013') payload = get_response(content, 'January 11, 2013')
self.assertHTMLEqual(content, payload['content'], "self closing ol") self.assertHTMLEqual(content, payload['content'], "self closing ol")
url = reverse('course_info_json', course_update_url = update_locator.url_reverse('course_info_update/')
kwargs={'org': self.course.location.org, resp = self.client.get_json(course_update_url)
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.get(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertTrue(len(payload) == 2) self.assertTrue(len(payload) == 2)
# try json w/o required fields # try json w/o required fields
self.assertContains(self.client.post(url, json.dumps({'garbage': 1}), self.assertContains(
"application/json"), self.client.ajax_post(course_update_url, {'garbage': 1}),
'Failed to save', status_code=400) 'Failed to save', status_code=400
)
# test an update with text in the tail of the header # test an update with text in the tail of the header
content = 'outside <strong>inside</strong> after' content = 'outside <strong>inside</strong> after'
...@@ -77,28 +76,22 @@ class CourseUpdateTest(CourseTestCase): ...@@ -77,28 +76,22 @@ class CourseUpdateTest(CourseTestCase):
self.assertHTMLEqual(content, payload['content'], "text outside tag") self.assertHTMLEqual(content, payload['content'], "text outside tag")
# now try to update a non-existent update # now try to update a non-existent update
url = reverse('course_info_json',
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': '9'})
content = 'blah blah' content = 'blah blah'
payload = {'content': content, payload = {'content': content, 'date': 'January 21, 2013'}
'date': 'January 21, 2013'}
self.assertContains( self.assertContains(
self.client.ajax_post(url, payload), self.client.ajax_post(course_update_url + '/9', payload),
'Failed to save', status_code=400) 'Failed to save', status_code=400
)
# update w/ malformed html # update w/ malformed html
content = '<garbage tag No closing brace to force <span>error</span>' content = '<garbage tag No closing brace to force <span>error</span>'
payload = {'content': content, payload = {'content': content,
'date': 'January 11, 2013'} 'date': 'January 11, 2013'}
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
self.assertContains( self.assertContains(
self.client.ajax_post(url, payload), self.client.ajax_post(course_update_url, payload),
'<garbage') '<garbage'
)
# set to valid html which would break an xml parser # set to valid html which would break an xml parser
content = "<p><br><br></p>" content = "<p><br><br></p>"
...@@ -106,10 +99,7 @@ class CourseUpdateTest(CourseTestCase): ...@@ -106,10 +99,7 @@ class CourseUpdateTest(CourseTestCase):
self.assertHTMLEqual(content, payload['content']) self.assertHTMLEqual(content, payload['content'])
# now try to delete a non-existent update # now try to delete a non-existent update
url = reverse('course_info_json', kwargs={'org': self.course.location.org, self.assertContains(self.client.delete(course_update_url + '/19'), "delete", status_code=400)
'course': self.course.location.course,
'provided_id': '19'})
self.assertContains(self.client.delete(url), "delete", status_code=400)
# now delete a real update # now delete a real update
content = 'blah blah' content = 'blah blah'
...@@ -117,18 +107,11 @@ class CourseUpdateTest(CourseTestCase): ...@@ -117,18 +107,11 @@ class CourseUpdateTest(CourseTestCase):
this_id = payload['id'] this_id = payload['id']
self.assertHTMLEqual(content, payload['content'], "single iframe") self.assertHTMLEqual(content, payload['content'], "single iframe")
# first count the entries # first count the entries
url = reverse('course_info_json', resp = self.client.get_json(course_update_url)
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.get(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
before_delete = len(payload) before_delete = len(payload)
url = reverse('course_info_json', url = update_locator.url_reverse('course_info_update/', str(this_id))
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': this_id})
resp = self.client.delete(url) resp = self.client.delete(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertTrue(len(payload) == before_delete - 1) self.assertTrue(len(payload) == before_delete - 1)
...@@ -144,24 +127,19 @@ class CourseUpdateTest(CourseTestCase): ...@@ -144,24 +127,19 @@ class CourseUpdateTest(CourseTestCase):
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">' init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
content = init_content + '</iframe>' content = init_content + '</iframe>'
payload = {'content': content, payload = {'content': content, 'date': 'January 8, 2013'}
'date': 'January 8, 2013'}
url = reverse('course_info_json',
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.ajax_post(url, payload) update_locator = loc_mapper().translate_location(
self.course.location.course_id, location, False, True
)
course_update_url = update_locator.url_reverse('course_info_update/')
resp = self.client.ajax_post(course_update_url, payload)
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertHTMLEqual(payload['content'], content) self.assertHTMLEqual(payload['content'], content)
# now confirm that the bad news and the iframe make up 2 updates # now confirm that the bad news and the iframe make up 2 updates
url = reverse('course_info_json', resp = self.client.get_json(course_update_url)
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.get(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertTrue(len(payload) == 2) self.assertTrue(len(payload) == 2)
...@@ -49,6 +49,12 @@ class AjaxEnabledTestClient(Client): ...@@ -49,6 +49,12 @@ class AjaxEnabledTestClient(Client):
""" """
return self.get(path, data or {}, follow, HTTP_ACCEPT="text/html", **extra) return self.get(path, data or {}, follow, HTTP_ACCEPT="text/html", **extra)
def get_json(self, path, data=None, follow=False, **extra):
"""
Convenience method for client.get which sets the accept type to json
"""
return self.get(path, data or {}, follow, HTTP_ACCEPT="application/json", **extra)
@override_settings(MODULESTORE=TEST_MODULESTORE) @override_settings(MODULESTORE=TEST_MODULESTORE)
class CourseTestCase(ModuleStoreTestCase): class CourseTestCase(ModuleStoreTestCase):
def setUp(self): def setUp(self):
......
...@@ -21,9 +21,7 @@ from xmodule.modulestore.django import loc_mapper ...@@ -21,9 +21,7 @@ from xmodule.modulestore.django import loc_mapper
from xblock.fields import Scope from xblock.fields import Scope
from util.json_request import expect_json, JsonResponse from util.json_request import expect_json, JsonResponse
from contentstore.module_info_model import get_module_info, set_module_info from contentstore.utils import get_lms_link_for_item, compute_unit_state, UnitState, get_course_for_item
from contentstore.utils import (get_modulestore, get_lms_link_for_item,
compute_unit_state, UnitState, get_course_for_item)
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
...@@ -41,7 +39,7 @@ __all__ = ['OPEN_ENDED_COMPONENT_TYPES', ...@@ -41,7 +39,7 @@ __all__ = ['OPEN_ENDED_COMPONENT_TYPES',
'create_draft', 'create_draft',
'publish_draft', 'publish_draft',
'unpublish_unit', 'unpublish_unit',
'module_info'] ]
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -240,7 +238,7 @@ def edit_unit(request, location): ...@@ -240,7 +238,7 @@ def edit_unit(request, location):
pass pass
else: else:
log.error( log.error(
"Improper format for course advanced keys! %", "Improper format for course advanced keys! %s",
course_advanced_keys course_advanced_keys
) )
...@@ -393,39 +391,3 @@ def unpublish_unit(request): ...@@ -393,39 +391,3 @@ def unpublish_unit(request):
_xmodule_recurse(item, lambda i: modulestore().unpublish(i.location)) _xmodule_recurse(item, lambda i: modulestore().unpublish(i.location))
return HttpResponse() return HttpResponse()
@expect_json
@require_http_methods(("GET", "POST", "PUT"))
@login_required
@ensure_csrf_cookie
def module_info(request, module_location):
"Get or set information for a module in the modulestore"
location = Location(module_location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
logging.debug('rewrite_static_links = {0} {1}'.format(
request.GET.get('rewrite_url_links', False),
rewrite_static_links)
)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
if request.method == 'GET':
rsp = get_module_info(
get_modulestore(location),
location,
rewrite_static_links=rewrite_static_links
)
elif request.method in ("POST", "PUT"):
rsp = set_module_info(
get_modulestore(location),
location, request.json
)
return JsonResponse(rsp)
...@@ -54,8 +54,8 @@ from xmodule.html_module import AboutDescriptor ...@@ -54,8 +54,8 @@ from xmodule.html_module import AboutDescriptor
from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.locator import BlockUsageLocator
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
__all__ = ['course_info', 'course_handler', __all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler',
'course_info_updates', 'get_course_settings', 'get_course_settings',
'course_config_graders_page', 'course_config_graders_page',
'course_config_advanced_page', 'course_config_advanced_page',
'course_settings_updates', 'course_settings_updates',
...@@ -64,6 +64,7 @@ __all__ = ['course_info', 'course_handler', ...@@ -64,6 +64,7 @@ __all__ = ['course_info', 'course_handler',
'create_textbook'] 'create_textbook']
# pylint: disable=unused-argument
@login_required @login_required
def course_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None): def course_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
""" """
...@@ -299,61 +300,80 @@ def create_new_course(request): ...@@ -299,61 +300,80 @@ def create_new_course(request):
return JsonResponse({'url': new_location.url_reverse("course/", "")}) return JsonResponse({'url': new_location.url_reverse("course/", "")})
# pylint: disable=unused-argument
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def course_info(request, org, course, name, provided_id=None): @require_http_methods(["GET"])
def course_info_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
""" """
Send models and views as well as html for editing the course info to the GET
client. html: return html for editing the course info handouts and updates.
org, course, name: Attributes of the Location for the item to edit
""" """
location = get_location_and_verify_access(request, org, course, name) course_location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
course_old_location = loc_mapper().translate_locator_to_location(course_location)
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
if not has_access(request.user, course_location):
raise PermissionDenied()
course_module = modulestore().get_item(location) course_module = modulestore().get_item(course_old_location)
# get current updates handouts_old_location = course_old_location.replace(category='course_info', name='handouts')
location = Location(['i4x', org, course, 'course_info', "updates"]) handouts_locator = loc_mapper().translate_location(
course_old_location.course_id, handouts_old_location, False, True
)
return render_to_response( update_location = course_old_location.replace(category='course_info', name='updates')
'course_info.html', update_locator = loc_mapper().translate_location(
{ course_old_location.course_id, update_location, False, True
'context_course': course_module, )
'url_base': "/" + org + "/" + course + "/",
'course_updates': json.dumps(get_course_updates(location)),
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url(),
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(location) + '/'
}
)
@expect_json return render_to_response(
@require_http_methods(("GET", "POST", "PUT", "DELETE")) 'course_info.html',
{
'context_course': course_module,
'updates_url': update_locator.url_reverse('course_info_update/'),
'handouts_locator': handouts_locator,
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(course_old_location) + '/'
}
)
else:
return HttpResponseBadRequest("Only supports html requests")
# pylint: disable=unused-argument
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def course_info_updates(request, org, course, provided_id=None): @require_http_methods(("GET", "POST", "PUT", "DELETE"))
@expect_json
def course_info_update_handler(
request, tag=None, course_id=None, branch=None, version_guid=None, block=None, provided_id=None
):
""" """
restful CRUD operations on course_info updates. restful CRUD operations on course_info updates.
provided_id should be none if it's new (create) and index otherwise.
org, course: Attributes of the Location for the item to edit GET
provided_id should be none if it's new (create) and a composite of the json: return the course info update models
update db id + index otherwise. POST
""" json: create an update
# ??? No way to check for access permission afaik PUT or DELETE
# get current updates json: change an existing update
location = ['i4x', org, course, 'course_info', "updates"] """
if 'application/json' not in request.META.get('HTTP_ACCEPT', 'application/json'):
return HttpResponseBadRequest("Only supports json requests")
updates_locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
updates_location = loc_mapper().translate_locator_to_location(updates_locator)
if provided_id == '': if provided_id == '':
provided_id = None provided_id = None
# check that logged in user has permissions to this item # check that logged in user has permissions to this item (GET shouldn't require this level?)
if not has_access(request.user, location): if not has_access(request.user, updates_location):
raise PermissionDenied() raise PermissionDenied()
if request.method == 'GET': if request.method == 'GET':
return JsonResponse(get_course_updates(location)) return JsonResponse(get_course_updates(updates_location, provided_id))
elif request.method == 'DELETE': elif request.method == 'DELETE':
try: try:
return JsonResponse(delete_course_update(location, request.json, provided_id)) return JsonResponse(delete_course_update(updates_location, request.json, provided_id))
except: except:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Failed to delete", "Failed to delete",
...@@ -362,7 +382,7 @@ def course_info_updates(request, org, course, provided_id=None): ...@@ -362,7 +382,7 @@ def course_info_updates(request, org, course, provided_id=None):
# can be either and sometimes django is rewriting one to the other: # can be either and sometimes django is rewriting one to the other:
elif request.method in ('POST', 'PUT'): elif request.method in ('POST', 'PUT'):
try: try:
return JsonResponse(update_course_updates(location, request.json, provided_id)) return JsonResponse(update_course_updates(updates_location, request.json, provided_id))
except: except:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Failed to save", "Failed to save",
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
import logging import logging
from uuid import uuid4 from uuid import uuid4
from requests.packages.urllib3.util import parse_url from requests.packages.urllib3.util import parse_url
from static_replace import replace_static_urls
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
...@@ -24,6 +25,7 @@ from xmodule.x_module import XModuleDescriptor ...@@ -24,6 +25,7 @@ from xmodule.x_module import XModuleDescriptor
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.locator import BlockUsageLocator
from student.models import CourseEnrollment from student.models import CourseEnrollment
from xblock.fields import Scope
__all__ = ['save_item', 'create_item', 'orphan', 'xblock_handler'] __all__ = ['save_item', 'create_item', 'orphan', 'xblock_handler']
...@@ -33,7 +35,8 @@ log = logging.getLogger(__name__) ...@@ -33,7 +35,8 @@ log = logging.getLogger(__name__)
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
@require_http_methods(("DELETE")) # pylint: disable=unused-argument
@require_http_methods(("DELETE", "GET", "PUT", "POST"))
@login_required @login_required
@expect_json @expect_json
def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None): def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
...@@ -44,10 +47,19 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -44,10 +47,19 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=
json: delete this xblock instance from the course. Supports query parameters "recurse" to delete json: delete this xblock instance from the course. Supports query parameters "recurse" to delete
all children and "all_versions" to delete from all (mongo) versions. all children and "all_versions" to delete from all (mongo) versions.
""" """
if request.method == 'DELETE': location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) if not has_access(request.user, location):
if not has_access(request.user, location): raise PermissionDenied()
raise PermissionDenied()
if request.method == 'GET':
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
rsp = _get_module_info(location, rewrite_static_links=rewrite_static_links)
return JsonResponse(rsp)
elif request.method in ("POST", "PUT"):
# Replace w/ save_item from below
rsp = _set_module_info(location, request.json)
return JsonResponse(rsp)
elif request.method == 'DELETE':
old_location = loc_mapper().translate_locator_to_location(location) old_location = loc_mapper().translate_locator_to_location(location)
...@@ -261,3 +273,104 @@ def orphan(request, tag=None, course_id=None, branch=None, version_guid=None, bl ...@@ -261,3 +273,104 @@ def orphan(request, tag=None, course_id=None, branch=None, version_guid=None, bl
return JsonResponse({'deleted': items}) return JsonResponse({'deleted': items})
else: else:
raise PermissionDenied() raise PermissionDenied()
def _get_module_info(usage_loc, rewrite_static_links=False):
"""
metadata, data, id representation of a leaf module fetcher.
:param usage_loc: A BlockUsageLocator
"""
old_location = loc_mapper().translate_locator_to_location(usage_loc)
store = get_modulestore(old_location)
try:
module = store.get_item(old_location)
except ItemNotFoundError:
if old_location.category in ['course_info']:
# create a new one
store.create_and_save_xmodule(old_location)
module = store.get_item(old_location)
else:
raise
data = module.data
if rewrite_static_links:
# we pass a partially bogus course_id as we don't have the RUN information passed yet
# through the CMS. Also the contentstore is also not RUN-aware at this point in time.
data = replace_static_urls(
module.data,
None,
course_id=module.location.org + '/' + module.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
)
return {
'id': unicode(usage_loc),
'data': data,
'metadata': module.get_explicitly_set_fields_by_scope(Scope.settings)
}
def _set_module_info(usage_loc, post_data):
"""
Old metadata, data, id representation leaf module updater.
:param usage_loc: a BlockUsageLocator
:param post_data: the payload with data, metadata, and possibly children (even tho the getter
doesn't support children)
"""
# TODO replace with save_item: differences
# - this doesn't handle nullout
# - this returns the new model
old_location = loc_mapper().translate_locator_to_location(usage_loc)
store = get_modulestore(old_location)
module = None
try:
module = store.get_item(old_location)
except ItemNotFoundError:
# new module at this location: almost always used for the course about pages; thus, no parent. (there
# are quite a handful of about page types available for a course and only the overview is pre-created)
store.create_and_save_xmodule(old_location)
module = store.get_item(old_location)
if post_data.get('data') is not None:
data = post_data['data']
store.update_item(old_location, data)
else:
data = module.get_explicitly_set_fields_by_scope(Scope.content)
if post_data.get('children') is not None:
children = post_data['children']
store.update_children(old_location, children)
# cdodge: also commit any metadata which might have been passed along in the
# POST from the client, if it is there
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
# not presented to the end-user for editing. So let's fetch the original and
# 'apply' the submitted metadata, so we don't end up deleting system metadata
if post_data.get('metadata') is not None:
posted_metadata = post_data['metadata']
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
for metadata_key, value in posted_metadata.items():
field = module.fields[metadata_key]
if value is None:
# remove both from passed in collection as well as the collection read in from the modulestore
field.delete_from(module)
else:
try:
value = field.from_json(value)
except ValueError:
return JsonResponse({"error": "Invalid data"}, 400)
field.write_to(module, value)
# commit to datastore
metadata = module.get_explicitly_set_fields_by_scope(Scope.settings)
store.update_metadata(old_location, metadata)
else:
metadata = module.get_explicitly_set_fields_by_scope(Scope.settings)
return {
'id': unicode(usage_loc),
'data': data,
'metadata': metadata
}
...@@ -34,6 +34,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model ...@@ -34,6 +34,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
@xhrRestore = courseUpdatesXhr.restore @xhrRestore = courseUpdatesXhr.restore
@collection = new CourseUpdateCollection() @collection = new CourseUpdateCollection()
@collection.url = 'course_info_update/'
@courseInfoEdit = new CourseInfoUpdateView({ @courseInfoEdit = new CourseInfoUpdateView({
el: $('.course-updates'), el: $('.course-updates'),
collection: @collection, collection: @collection,
......
...@@ -4,7 +4,7 @@ define(["backbone", "js/models/course_update"], function(Backbone, CourseUpdateM ...@@ -4,7 +4,7 @@ define(["backbone", "js/models/course_update"], function(Backbone, CourseUpdateM
collection of updates as [{ date : "month day", content : "html"}] collection of updates as [{ date : "month day", content : "html"}]
*/ */
var CourseUpdateCollection = Backbone.Collection.extend({ var CourseUpdateCollection = Backbone.Collection.extend({
url : function() {return this.urlbase + "course_info/updates/";}, // instantiator must set url
model : CourseUpdateModel model : CourseUpdateModel
}); });
......
define(["backbone"], function(Backbone) { define(["backbone"], function(Backbone) {
var ModuleInfo = Backbone.Model.extend({ var ModuleInfo = Backbone.Model.extend({
url: function() {return "/module_info/" + this.id;}, urlRoot: "/xblock",
defaults: { defaults: {
"id": null, "id": null,
......
<%! from django.utils.translation import ugettext as _ %> <%!
from django.utils.translation import ugettext as _
%>
<%inherit file="base.html" /> <%inherit file="base.html" />
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
...@@ -17,16 +19,16 @@ ...@@ -17,16 +19,16 @@
<%block name="jsextra"> <%block name="jsextra">
<script type="text/javascript" charset="utf-8"> <script type="text/javascript" charset="utf-8">
require(["domReady!", "jquery", "js/collections/course_update", "js/models/module_info", "js/models/course_info", "js/views/course_info_edit"], require(["domReady!", "jquery", "js/collections/course_update", "js/models/module_info", "js/models/course_info", "js/views/course_info_edit"],
function(doc, $, CourseUpdateCollection, ModuleInfoModel, CourseInfoModel, CourseInfoEditView) { function(doc, $, CourseUpdateCollection, ModuleInfoModel, CourseInfoModel, CourseInfoEditView) {
var course_updates = new CourseUpdateCollection(); var course_updates = new CourseUpdateCollection();
course_updates.urlbase = '${url_base}'; course_updates.url = '${updates_url}';
course_updates.fetch({reset: true}); course_updates.fetch({reset: true});
var course_handouts = new ModuleInfoModel({ var course_handouts = new ModuleInfoModel({
id: '${handouts_location}' id: '${handouts_locator}'
}); });
course_handouts.urlbase = '${url_base}';
var editor = new CourseInfoEditView({ var editor = new CourseInfoEditView({
el: $('.main-wrapper'), el: $('.main-wrapper'),
......
...@@ -16,11 +16,12 @@ ...@@ -16,11 +16,12 @@
<% <%
ctx_loc = context_course.location ctx_loc = context_course.location
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
index_url = location.url_reverse('course/', '') index_url = location.url_reverse('course/')
checklists_url = location.url_reverse('checklists/', '') checklists_url = location.url_reverse('checklists/')
course_team_url = location.url_reverse('course_team/', '') course_team_url = location.url_reverse('course_team/')
assets_url = location.url_reverse('assets/', '') assets_url = location.url_reverse('assets/')
import_url = location.url_reverse('import/', '') import_url = location.url_reverse('import/')
course_info_url = location.url_reverse('course_info/')
export_url = location.url_reverse('export/', '') export_url = location.url_reverse('export/', '')
%> %>
<h2 class="info-course"> <h2 class="info-course">
...@@ -44,7 +45,7 @@ ...@@ -44,7 +45,7 @@
<a href="${index_url}">${_("Outline")}</a> <a href="${index_url}">${_("Outline")}</a>
</li> </li>
<li class="nav-item nav-course-courseware-updates"> <li class="nav-item nav-course-courseware-updates">
<a href="${reverse('course_info', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Updates")}</a> <a href="${course_info_url}">${_("Updates")}</a>
</li> </li>
<li class="nav-item nav-course-courseware-pages"> <li class="nav-item nav-course-courseware-pages">
<a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}">${_("Static Pages")}</a> <a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}">${_("Static Pages")}</a>
......
...@@ -34,10 +34,6 @@ urlpatterns = patterns('', # nopep8 ...@@ -34,10 +34,6 @@ urlpatterns = patterns('', # nopep8
url(r'^preview/xblock/(?P<usage_id>.*?)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>[^/]*))?$', url(r'^preview/xblock/(?P<usage_id>.*?)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>[^/]*))?$',
'contentstore.views.preview_handler', name='preview_handler'), 'contentstore.views.preview_handler', name='preview_handler'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/info/(?P<name>[^/]+)$',
'contentstore.views.course_info', name='course_info'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course_info/updates/(?P<provided_id>.*)$',
'contentstore.views.course_info_updates', name='course_info_json'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)$',
'contentstore.views.get_course_settings', name='settings_details'), 'contentstore.views.get_course_settings', name='settings_details'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$',
...@@ -66,11 +62,6 @@ urlpatterns = patterns('', # nopep8 ...@@ -66,11 +62,6 @@ urlpatterns = patterns('', # nopep8
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/(?P<tid>\d[^/]*)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/(?P<tid>\d[^/]*)$',
'contentstore.views.textbook_by_id', name='textbook_by_id'), 'contentstore.views.textbook_by_id', name='textbook_by_id'),
# this is a generic method to return the data/metadata associated with a xmodule
url(r'^module_info/(?P<module_location>.*)$',
'contentstore.views.module_info', name='module_info'),
# temporary landing page for a course # temporary landing page for a course
url(r'^edge/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', url(r'^edge/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
'contentstore.views.landing', name='landing'), 'contentstore.views.landing', name='landing'),
...@@ -112,6 +103,11 @@ urlpatterns += patterns( ...@@ -112,6 +103,11 @@ urlpatterns += patterns(
url(r'^request_course_creator$', 'request_course_creator'), url(r'^request_course_creator$', 'request_course_creator'),
# (?ix) == ignore case and verbose (multiline regex) # (?ix) == ignore case and verbose (multiline regex)
url(r'(?ix)^course_team/{}(/)?(?P<email>.+)?$'.format(parsers.URL_RE_SOURCE), 'course_team_handler'), url(r'(?ix)^course_team/{}(/)?(?P<email>.+)?$'.format(parsers.URL_RE_SOURCE), 'course_team_handler'),
url(r'(?ix)^course_info/{}$'.format(parsers.URL_RE_SOURCE), 'course_info_handler'),
url(
r'(?ix)^course_info_update/{}(/)?(?P<provided_id>\d+)?$'.format(parsers.URL_RE_SOURCE),
'course_info_update_handler'
),
url(r'(?ix)^course($|/){}$'.format(parsers.URL_RE_SOURCE), 'course_handler'), url(r'(?ix)^course($|/){}$'.format(parsers.URL_RE_SOURCE), 'course_handler'),
url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'), url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'),
url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan'), url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan'),
......
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