RESTful refactoring for course_info updates and handouts.
html page and update access use 2 different urls GET update can get an individual update STUD-944
This commit is contained in:
@@ -1,20 +1,20 @@
|
||||
import re
|
||||
import logging
|
||||
from lxml import html, etree
|
||||
from django.http import HttpResponseBadRequest
|
||||
import django.utils
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from lxml import html, etree
|
||||
import re
|
||||
from django.http import HttpResponseBadRequest
|
||||
import logging
|
||||
import django.utils
|
||||
|
||||
# # 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
|
||||
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:
|
||||
[{id : location.url() + idx to make unique, date : string, content : html string}]
|
||||
[{id : index, date : string, content : html string}]
|
||||
"""
|
||||
try:
|
||||
course_updates = modulestore('direct').get_item(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
|
||||
course_upd_collection = []
|
||||
provided_id = get_idx(provided_id) if provided_id is not None else None
|
||||
if course_html_parsed.tag == 'ol':
|
||||
# 0 is the newest
|
||||
for idx, update in enumerate(course_html_parsed):
|
||||
if len(update) > 0:
|
||||
content = _course_info_content(update)
|
||||
# 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),
|
||||
"date": update.findtext("h2"),
|
||||
"content": content})
|
||||
computed_id = len(course_html_parsed) - idx
|
||||
payload = {
|
||||
"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
|
||||
|
||||
@@ -57,7 +65,8 @@ def update_course_updates(location, update, passed_id=None):
|
||||
try:
|
||||
course_updates = modulestore('direct').get_item(location)
|
||||
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.
|
||||
try:
|
||||
@@ -89,17 +98,17 @@ def update_course_updates(location, update, passed_id=None):
|
||||
course_html_parsed[-idx] = new_html_parsed
|
||||
else:
|
||||
course_html_parsed.insert(0, new_html_parsed)
|
||||
|
||||
idx = len(course_html_parsed)
|
||||
passed_id = course_updates.location.url() + "/" + str(idx)
|
||||
|
||||
# update db record
|
||||
course_updates.data = html.tostring(course_html_parsed)
|
||||
modulestore('direct').update_item(location, course_updates.data)
|
||||
|
||||
return {"id": passed_id,
|
||||
"date": update['date'],
|
||||
"content": _course_info_content(new_html_parsed)}
|
||||
return {
|
||||
"id": idx,
|
||||
"date": update['date'],
|
||||
"content": _course_info_content(new_html_parsed),
|
||||
}
|
||||
|
||||
|
||||
def _course_info_content(html_parsed):
|
||||
@@ -115,6 +124,7 @@ def _course_info_content(html_parsed):
|
||||
return content
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def delete_course_update(location, update, passed_id):
|
||||
"""
|
||||
Delete the given course_info update from the db.
|
||||
@@ -150,7 +160,7 @@ def delete_course_update(location, update, passed_id):
|
||||
store = modulestore('direct')
|
||||
store.update_item(location, course_updates.data)
|
||||
|
||||
return get_course_updates(location)
|
||||
return get_course_updates(location, None)
|
||||
|
||||
|
||||
def get_idx(passed_id):
|
||||
@@ -160,3 +170,5 @@ def get_idx(passed_id):
|
||||
idx_matcher = re.search(r'.*?/?(\d+)$', passed_id)
|
||||
if idx_matcher:
|
||||
return int(idx_matcher.group(1))
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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):
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
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
|
||||
resp = self.client.get_html(reverse('module_info', kwargs={'module_location': handout_location}))
|
||||
# get module info (json)
|
||||
resp = self.client.get(handouts_locator.url_reverse('/xblock', ''))
|
||||
|
||||
# make sure we got a successful response
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
@@ -1600,10 +1602,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# course info
|
||||
resp = self.client.get(reverse('course_info',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
resp = self.client.get(new_location.url_reverse('course_info'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# settings_details
|
||||
@@ -1627,14 +1626,15 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
# go look at a subsection page
|
||||
subsection_location = loc.replace(category='sequential', name='test_sequence')
|
||||
resp = self.client.get_html(reverse('edit_subsection',
|
||||
kwargs={'location': subsection_location.url()}))
|
||||
resp = self.client.get_html(
|
||||
reverse('edit_subsection', kwargs={'location': subsection_location.url()})
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# go look at the Edit page
|
||||
unit_location = loc.replace(category='vertical', name='test_vertical')
|
||||
resp = self.client.get_html(reverse('edit_unit',
|
||||
kwargs={'location': unit_location.url()}))
|
||||
resp = self.client.get_html(
|
||||
reverse('edit_unit', kwargs={'location': unit_location.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def delete_item(category, name):
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'''unit tests for course_info views and models.'''
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
import json
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.django import modulestore, loc_mapper
|
||||
|
||||
|
||||
class CourseUpdateTest(CourseTestCase):
|
||||
@@ -15,61 +14,61 @@ class CourseUpdateTest(CourseTestCase):
|
||||
|
||||
Does not supply a provided_id.
|
||||
"""
|
||||
payload = {'content': content,
|
||||
'date': date}
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
payload = {'content': content, 'date': date}
|
||||
url = update_locator.url_reverse('course_info_update/')
|
||||
|
||||
resp = self.client.ajax_post(url, payload)
|
||||
self.assertContains(resp, '', status_code=200)
|
||||
|
||||
return json.loads(resp.content)
|
||||
|
||||
# first get the update to force the creation
|
||||
url = reverse('course_info',
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name})
|
||||
self.client.get(url)
|
||||
course_locator = loc_mapper().translate_location(
|
||||
self.course.location.course_id, self.course.location, False, True
|
||||
)
|
||||
resp = self.client.get_html(course_locator.url_reverse('course_info/'))
|
||||
self.assertContains(resp, 'Course Updates', status_code=200)
|
||||
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">'
|
||||
content = init_content + '</iframe>'
|
||||
payload = get_response(content, 'January 8, 2013')
|
||||
self.assertHTMLEqual(payload['content'], content)
|
||||
|
||||
first_update_url = reverse('course_info_json',
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': payload['id']})
|
||||
first_update_url = update_locator.url_reverse('course_info_update', str(payload['id']))
|
||||
content += '<div>div <p>p<br/></p></div>'
|
||||
payload['content'] = content
|
||||
# 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),
|
||||
"application/json",
|
||||
HTTP_X_HTTP_METHOD_OVERRIDE="PUT",
|
||||
REQUEST_METHOD="POST")
|
||||
resp = self.client.ajax_post(
|
||||
first_update_url, payload, HTTP_X_HTTP_METHOD_OVERRIDE="PUT", REQUEST_METHOD="POST"
|
||||
)
|
||||
|
||||
self.assertHTMLEqual(content, json.loads(resp.content)['content'],
|
||||
"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
|
||||
content = '<ol/>'
|
||||
payload = get_response(content, 'January 11, 2013')
|
||||
self.assertHTMLEqual(content, payload['content'], "self closing ol")
|
||||
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
course_update_url = update_locator.url_reverse('course_info_update/')
|
||||
resp = self.client.get_json(course_update_url)
|
||||
payload = json.loads(resp.content)
|
||||
self.assertTrue(len(payload) == 2)
|
||||
|
||||
# try json w/o required fields
|
||||
self.assertContains(self.client.post(url, json.dumps({'garbage': 1}),
|
||||
"application/json"),
|
||||
'Failed to save', status_code=400)
|
||||
self.assertContains(
|
||||
self.client.ajax_post(course_update_url, {'garbage': 1}),
|
||||
'Failed to save', status_code=400
|
||||
)
|
||||
|
||||
# test an update with text in the tail of the header
|
||||
content = 'outside <strong>inside</strong> after'
|
||||
@@ -77,28 +76,22 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(content, payload['content'], "text outside tag")
|
||||
|
||||
# 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'
|
||||
payload = {'content': content,
|
||||
'date': 'January 21, 2013'}
|
||||
payload = {'content': content, 'date': 'January 21, 2013'}
|
||||
self.assertContains(
|
||||
self.client.ajax_post(url, payload),
|
||||
'Failed to save', status_code=400)
|
||||
self.client.ajax_post(course_update_url + '/9', payload),
|
||||
'Failed to save', status_code=400
|
||||
)
|
||||
|
||||
# update w/ malformed html
|
||||
content = '<garbage tag No closing brace to force <span>error</span>'
|
||||
payload = {'content': content,
|
||||
'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.client.ajax_post(url, payload),
|
||||
'<garbage')
|
||||
self.client.ajax_post(course_update_url, payload),
|
||||
'<garbage'
|
||||
)
|
||||
|
||||
# set to valid html which would break an xml parser
|
||||
content = "<p><br><br></p>"
|
||||
@@ -106,10 +99,7 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(content, payload['content'])
|
||||
|
||||
# now try to delete a non-existent update
|
||||
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': '19'})
|
||||
self.assertContains(self.client.delete(url), "delete", status_code=400)
|
||||
self.assertContains(self.client.delete(course_update_url + '/19'), "delete", status_code=400)
|
||||
|
||||
# now delete a real update
|
||||
content = 'blah blah'
|
||||
@@ -117,18 +107,11 @@ class CourseUpdateTest(CourseTestCase):
|
||||
this_id = payload['id']
|
||||
self.assertHTMLEqual(content, payload['content'], "single iframe")
|
||||
# first count the entries
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
resp = self.client.get_json(course_update_url)
|
||||
payload = json.loads(resp.content)
|
||||
before_delete = len(payload)
|
||||
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': this_id})
|
||||
url = update_locator.url_reverse('course_info_update/', str(this_id))
|
||||
resp = self.client.delete(url)
|
||||
payload = json.loads(resp.content)
|
||||
self.assertTrue(len(payload) == before_delete - 1)
|
||||
@@ -144,24 +127,19 @@ class CourseUpdateTest(CourseTestCase):
|
||||
|
||||
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
|
||||
content = init_content + '</iframe>'
|
||||
payload = {'content': content,
|
||||
'date': 'January 8, 2013'}
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
payload = {'content': content, 'date': 'January 8, 2013'}
|
||||
|
||||
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)
|
||||
|
||||
self.assertHTMLEqual(payload['content'], content)
|
||||
|
||||
# now confirm that the bad news and the iframe make up 2 updates
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
resp = self.client.get_json(course_update_url)
|
||||
payload = json.loads(resp.content)
|
||||
self.assertTrue(len(payload) == 2)
|
||||
|
||||
@@ -49,6 +49,12 @@ class AjaxEnabledTestClient(Client):
|
||||
"""
|
||||
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)
|
||||
class CourseTestCase(ModuleStoreTestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -21,9 +21,7 @@ from xmodule.modulestore.django import loc_mapper
|
||||
from xblock.fields import Scope
|
||||
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_modulestore, get_lms_link_for_item,
|
||||
compute_unit_state, UnitState, get_course_for_item)
|
||||
from contentstore.utils import get_lms_link_for_item, compute_unit_state, UnitState, get_course_for_item
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
@@ -41,7 +39,7 @@ __all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'create_draft',
|
||||
'publish_draft',
|
||||
'unpublish_unit',
|
||||
'module_info']
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -240,7 +238,7 @@ def edit_unit(request, location):
|
||||
pass
|
||||
else:
|
||||
log.error(
|
||||
"Improper format for course advanced keys! %",
|
||||
"Improper format for course advanced keys! %s",
|
||||
course_advanced_keys
|
||||
)
|
||||
|
||||
@@ -393,39 +391,3 @@ def unpublish_unit(request):
|
||||
_xmodule_recurse(item, lambda i: modulestore().unpublish(i.location))
|
||||
|
||||
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
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
|
||||
|
||||
__all__ = ['course_info', 'course_handler',
|
||||
'course_info_updates', 'get_course_settings',
|
||||
__all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler',
|
||||
'get_course_settings',
|
||||
'course_config_graders_page',
|
||||
'course_config_advanced_page',
|
||||
'course_settings_updates',
|
||||
@@ -64,6 +64,7 @@ __all__ = ['course_info', 'course_handler',
|
||||
'create_textbook']
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@login_required
|
||||
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):
|
||||
return JsonResponse({'url': new_location.url_reverse("course/", "")})
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@login_required
|
||||
@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
|
||||
client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
GET
|
||||
html: return html for editing the course info handouts and updates.
|
||||
"""
|
||||
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
|
||||
location = Location(['i4x', org, course, 'course_info', "updates"])
|
||||
handouts_old_location = course_old_location.replace(category='course_info', name='handouts')
|
||||
handouts_locator = loc_mapper().translate_location(
|
||||
course_old_location.course_id, handouts_old_location, False, True
|
||||
)
|
||||
|
||||
return render_to_response(
|
||||
'course_info.html',
|
||||
{
|
||||
'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) + '/'
|
||||
}
|
||||
)
|
||||
update_location = course_old_location.replace(category='course_info', name='updates')
|
||||
update_locator = loc_mapper().translate_location(
|
||||
course_old_location.course_id, update_location, False, True
|
||||
)
|
||||
|
||||
@expect_json
|
||||
return render_to_response(
|
||||
'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
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info_updates(request, org, course, provided_id=None):
|
||||
@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.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
provided_id should be none if it's new (create) and a composite of the
|
||||
update db id + index otherwise.
|
||||
provided_id should be none if it's new (create) and index otherwise.
|
||||
GET
|
||||
json: return the course info update models
|
||||
POST
|
||||
json: create an update
|
||||
PUT or DELETE
|
||||
json: change an existing update
|
||||
"""
|
||||
# ??? No way to check for access permission afaik
|
||||
# get current updates
|
||||
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 == '':
|
||||
provided_id = None
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
# check that logged in user has permissions to this item (GET shouldn't require this level?)
|
||||
if not has_access(request.user, updates_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(get_course_updates(location))
|
||||
return JsonResponse(get_course_updates(updates_location, provided_id))
|
||||
elif request.method == 'DELETE':
|
||||
try:
|
||||
return JsonResponse(delete_course_update(location, request.json, provided_id))
|
||||
return JsonResponse(delete_course_update(updates_location, request.json, provided_id))
|
||||
except:
|
||||
return HttpResponseBadRequest(
|
||||
"Failed to delete",
|
||||
@@ -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:
|
||||
elif request.method in ('POST', 'PUT'):
|
||||
try:
|
||||
return JsonResponse(update_course_updates(location, request.json, provided_id))
|
||||
return JsonResponse(update_course_updates(updates_location, request.json, provided_id))
|
||||
except:
|
||||
return HttpResponseBadRequest(
|
||||
"Failed to save",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
from requests.packages.urllib3.util import parse_url
|
||||
from static_replace import replace_static_urls
|
||||
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -24,6 +25,7 @@ from xmodule.x_module import XModuleDescriptor
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from student.models import CourseEnrollment
|
||||
from xblock.fields import Scope
|
||||
|
||||
__all__ = ['save_item', 'create_item', 'orphan', 'xblock_handler']
|
||||
|
||||
@@ -33,7 +35,8 @@ log = logging.getLogger(__name__)
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
@require_http_methods(("DELETE"))
|
||||
# pylint: disable=unused-argument
|
||||
@require_http_methods(("DELETE", "GET", "PUT", "POST"))
|
||||
@login_required
|
||||
@expect_json
|
||||
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=
|
||||
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.
|
||||
"""
|
||||
if request.method == 'DELETE':
|
||||
location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, location):
|
||||
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)
|
||||
|
||||
@@ -261,3 +273,104 @@ def orphan(request, tag=None, course_id=None, branch=None, version_guid=None, bl
|
||||
return JsonResponse({'deleted': items})
|
||||
else:
|
||||
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
|
||||
@xhrRestore = courseUpdatesXhr.restore
|
||||
|
||||
@collection = new CourseUpdateCollection()
|
||||
@collection.url = 'course_info_update/'
|
||||
@courseInfoEdit = new CourseInfoUpdateView({
|
||||
el: $('.course-updates'),
|
||||
collection: @collection,
|
||||
|
||||
@@ -4,7 +4,7 @@ define(["backbone", "js/models/course_update"], function(Backbone, CourseUpdateM
|
||||
collection of updates as [{ date : "month day", content : "html"}]
|
||||
*/
|
||||
var CourseUpdateCollection = Backbone.Collection.extend({
|
||||
url : function() {return this.urlbase + "course_info/updates/";},
|
||||
// instantiator must set url
|
||||
|
||||
model : CourseUpdateModel
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
define(["backbone"], function(Backbone) {
|
||||
var ModuleInfo = Backbone.Model.extend({
|
||||
url: function() {return "/module_info/" + this.id;},
|
||||
urlRoot: "/xblock",
|
||||
|
||||
defaults: {
|
||||
"id": null,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
@@ -17,16 +19,16 @@
|
||||
|
||||
<%block name="jsextra">
|
||||
<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"],
|
||||
function(doc, $, CourseUpdateCollection, ModuleInfoModel, CourseInfoModel, CourseInfoEditView) {
|
||||
var course_updates = new CourseUpdateCollection();
|
||||
course_updates.urlbase = '${url_base}';
|
||||
course_updates.url = '${updates_url}';
|
||||
course_updates.fetch({reset: true});
|
||||
|
||||
var course_handouts = new ModuleInfoModel({
|
||||
id: '${handouts_location}'
|
||||
id: '${handouts_locator}'
|
||||
});
|
||||
course_handouts.urlbase = '${url_base}';
|
||||
|
||||
var editor = new CourseInfoEditView({
|
||||
el: $('.main-wrapper'),
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
<%
|
||||
ctx_loc = context_course.location
|
||||
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
|
||||
index_url = location.url_reverse('course/', '')
|
||||
checklists_url = location.url_reverse('checklists/', '')
|
||||
course_team_url = location.url_reverse('course_team/', '')
|
||||
assets_url = location.url_reverse('assets/', '')
|
||||
import_url = location.url_reverse('import/', '')
|
||||
index_url = location.url_reverse('course/')
|
||||
checklists_url = location.url_reverse('checklists/')
|
||||
course_team_url = location.url_reverse('course_team/')
|
||||
assets_url = location.url_reverse('assets/')
|
||||
import_url = location.url_reverse('import/')
|
||||
course_info_url = location.url_reverse('course_info/')
|
||||
export_url = location.url_reverse('export/', '')
|
||||
%>
|
||||
<h2 class="info-course">
|
||||
@@ -44,7 +45,7 @@
|
||||
<a href="${index_url}">${_("Outline")}</a>
|
||||
</li>
|
||||
<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 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>
|
||||
|
||||
14
cms/urls.py
14
cms/urls.py
@@ -34,10 +34,6 @@ urlpatterns = patterns('', # nopep8
|
||||
url(r'^preview/modx/(?P<preview_id>[^/]*)/(?P<location>.*?)/(?P<dispatch>[^/]*)$',
|
||||
'contentstore.views.preview_dispatch', name='preview_dispatch'),
|
||||
|
||||
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>[^/]+)$',
|
||||
'contentstore.views.get_course_settings', name='settings_details'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$',
|
||||
@@ -66,11 +62,6 @@ urlpatterns = patterns('', # nopep8
|
||||
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
|
||||
url(r'^module_info/(?P<module_location>.*)$',
|
||||
'contentstore.views.module_info', name='module_info'),
|
||||
|
||||
|
||||
# temporary landing page for a course
|
||||
url(r'^edge/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
|
||||
'contentstore.views.landing', name='landing'),
|
||||
@@ -112,6 +103,11 @@ urlpatterns += patterns(
|
||||
url(r'^request_course_creator$', 'request_course_creator'),
|
||||
# (?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_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)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'),
|
||||
url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan'),
|
||||
|
||||
Reference in New Issue
Block a user