Merge remote-tracking branch 'origin/feature/cale/cms-master' into

feature/dhm/cms-settings

Conflicts:
	cms/djangoapps/contentstore/course_info_model.py
	cms/djangoapps/contentstore/views.py
	cms/static/js/models/course_info.js
	cms/static/js/template_loader.js
	cms/static/js/views/course_info_edit.js
	cms/templates/base.html
	cms/templates/course_info.html
	cms/urls.py
This commit is contained in:
Don Mitchell
2012-12-11 10:55:10 -05:00
70 changed files with 2175 additions and 449 deletions

View File

@@ -4,7 +4,6 @@ from xmodule.modulestore.django import modulestore
from lxml import etree
import re
from django.http import HttpResponseBadRequest
from contentstore.utils import get_modulestore
## 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
@@ -14,10 +13,10 @@ def get_course_updates(location):
[{id : location.url() + idx to make unique, date : string, content : html string}]
"""
try:
course_updates = get_modulestore(location).get_item(location)
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"])
course_updates = get_modulestore(location).clone_item(template, Location(location))
course_updates = modulestore('direct').clone_item(template, Location(location))
# current db rep: {"_id" : locationjson, "definition" : { "data" : "<ol>[<li><h2>date</h2>content</li>]</ol>"} "metadata" : ignored}
location_base = course_updates.location.url()
@@ -54,7 +53,7 @@ def update_course_updates(location, update, passed_id=None):
into the html structure.
"""
try:
course_updates = get_modulestore(location).get_item(location)
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
return HttpResponseBadRequest
@@ -94,13 +93,13 @@ def update_course_updates(location, update, passed_id=None):
date_element = etree.SubElement(element, "h2")
date_element.text = update['date']
if new_html_parsed is not None:
element[1] = new_html_parsed
element.append(new_html_parsed)
else:
date_element.tail = update['content']
# update db record
course_updates.definition['data'] = etree.tostring(course_html_parsed)
get_modulestore(location).update_item(location, course_updates.definition['data'])
modulestore('direct').update_item(location, course_updates.definition['data'])
return {"id" : passed_id,
"date" : update['date'],
@@ -115,7 +114,7 @@ def delete_course_update(location, update, passed_id):
return HttpResponseBadRequest
try:
course_updates = get_modulestore(location).get_item(location)
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
return HttpResponseBadRequest
@@ -134,7 +133,7 @@ def delete_course_update(location, update, passed_id):
# update db record
course_updates.definition['data'] = etree.tostring(course_html_parsed)
store = get_modulestore(location)
store = modulestore('direct')
store.update_item(location, course_updates.definition['data'])
return get_course_updates(location)

View File

@@ -0,0 +1,28 @@
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.xml_importer import perform_xlint
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
unnamed_modules = 0
class Command(BaseCommand):
help = \
'''
Verify the structure of courseware as to it's suitability for import
To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)]
'''
def handle(self, *args, **options):
if len(args) == 0:
raise CommandError("import requires at least one argument: <data directory> [<course dir>...]")
data_dir = args[0]
if len(args) > 1:
course_dirs = args[1:]
else:
course_dirs = None
print "Importing. Data_dir={data}, course_dirs={courses}".format(
data=data_dir,
courses=course_dirs)
perform_xlint(data_dir, course_dirs, load_error_modules=False)

View File

@@ -0,0 +1,83 @@
import logging
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from lxml import etree
import re
from django.http import HttpResponseBadRequest, Http404
def get_module_info(store, location, parent_location = None):
try:
if location.revision is None:
module = store.get_item(location)
else:
module = store.get_item(location)
except ItemNotFoundError:
raise Http404
return {
'id': module.location.url(),
'data': module.definition['data'],
'metadata': module.metadata
}
def set_module_info(store, location, post_data):
module = None
isNew = False
try:
if location.revision is None:
module = store.get_item(location)
else:
module = store.get_item(location)
except:
pass
if module is None:
# new module at this location
# presume that we have an 'Empty' template
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
module = store.clone_item(template_location, location)
isNew = True
logging.debug('post = {0}'.format(post_data))
if post_data.get('data') is not None:
data = post_data['data']
logging.debug('data = {0}'.format(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 in posted_metadata.keys():
# let's strip out any metadata fields from the postback which have been identified as system metadata
# and therefore should not be user-editable, so we should accept them back from the client
if metadata_key in module.system_metadata_fields:
del posted_metadata[metadata_key]
elif posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if metadata_key in module.metadata:
del module.metadata[metadata_key]
del posted_metadata[metadata_key]
# overlay the new metadata over the modulestore sourced collection to support partial updates
module.metadata.update(posted_metadata)
# commit to datastore
store.update_metadata(location, module.metadata)

View File

@@ -0,0 +1,113 @@
from factory import Factory
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from time import gmtime
from uuid import uuid4
from xmodule.timeparse import stringify_time
def XMODULE_COURSE_CREATION(class_to_create, **kwargs):
return XModuleCourseFactory._create(class_to_create, **kwargs)
def XMODULE_ITEM_CREATION(class_to_create, **kwargs):
return XModuleItemFactory._create(class_to_create, **kwargs)
class XModuleCourseFactory(Factory):
"""
Factory for XModule courses.
"""
ABSTRACT_FACTORY = True
_creation_function = (XMODULE_COURSE_CREATION,)
@classmethod
def _create(cls, target_class, *args, **kwargs):
template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
org = kwargs.get('org')
number = kwargs.get('number')
display_name = kwargs.get('display_name')
location = Location('i4x', org, number,
'course', Location.clean(display_name))
store = modulestore('direct')
# Write the data to the mongo datastore
new_course = store.clone_item(template, location)
# This metadata code was copied from cms/djangoapps/contentstore/views.py
if display_name is not None:
new_course.metadata['display_name'] = display_name
new_course.metadata['data_dir'] = uuid4().hex
new_course.metadata['start'] = stringify_time(gmtime())
new_course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
{"type": "progress", "name": "Progress"}]
# Update the data in the mongo datastore
store.update_metadata(new_course.location.url(), new_course.own_metadata)
return new_course
class Course:
pass
class CourseFactory(XModuleCourseFactory):
FACTORY_FOR = Course
template = 'i4x://edx/templates/course/Empty'
org = 'MITx'
number = '999'
display_name = 'Robot Super Course'
class XModuleItemFactory(Factory):
"""
Factory for XModule items.
"""
ABSTRACT_FACTORY = True
_creation_function = (XMODULE_ITEM_CREATION,)
@classmethod
def _create(cls, target_class, *args, **kwargs):
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
parent_location = Location(kwargs.get('parent_location'))
template = Location(kwargs.get('template'))
display_name = kwargs.get('display_name')
store = modulestore('direct')
# This code was based off that in cms/djangoapps/contentstore/views.py
parent = store.get_item(parent_location)
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
new_item = store.clone_item(template, dest_location)
# TODO: This needs to be deleted when we have proper storage for static content
new_item.metadata['data_dir'] = parent.metadata['data_dir']
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
new_item.metadata['display_name'] = display_name
store.update_metadata(new_item.location.url(), new_item.own_metadata)
if new_item.location.category not in DETACHED_CATEGORIES:
store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
return new_item
class Item:
pass
class ItemFactory(XModuleItemFactory):
FACTORY_FOR = Item
parent_location = 'i4x://MITx/999/course/Robot_Super_Course'
template = 'i4x://edx/templates/chapter/Empty'
display_name = 'Section One'

View File

@@ -1,7 +1,6 @@
import json
from django.test import TestCase
from django.test.client import Client
from mock import patch, Mock
from override_settings import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse
@@ -9,11 +8,10 @@ from path import path
from student.models import Registration
from django.contrib.auth.models import User
from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django
from xmodule.modulestore import Location
from xmodule.modulestore.xml_importer import import_from_xml
import copy
from factories import *
def parse_json(response):
@@ -22,33 +20,33 @@ def parse_json(response):
def user(email):
'''look up a user by email'''
"""look up a user by email"""
return User.objects.get(email=email)
def registration(email):
'''look up registration object by email'''
"""look up registration object by email"""
return Registration.objects.get(user__email=email)
class ContentStoreTestCase(TestCase):
def _login(self, email, pw):
'''Login. View should always return 200. The success/fail is in the
returned json'''
"""Login. View should always return 200. The success/fail is in the
returned json"""
resp = self.client.post(reverse('login_post'),
{'email': email, 'password': pw})
self.assertEqual(resp.status_code, 200)
return resp
def login(self, email, pw):
'''Login, check that it worked.'''
"""Login, check that it worked."""
resp = self._login(email, pw)
data = parse_json(resp)
self.assertTrue(data['success'])
return resp
def _create_account(self, username, email, pw):
'''Try to create an account. No error checking'''
"""Try to create an account. No error checking"""
resp = self.client.post('/create_account', {
'username': username,
'email': email,
@@ -62,7 +60,7 @@ class ContentStoreTestCase(TestCase):
return resp
def create_account(self, username, email, pw):
'''Create the account and check that it worked'''
"""Create the account and check that it worked"""
resp = self._create_account(username, email, pw)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
@@ -74,8 +72,8 @@ class ContentStoreTestCase(TestCase):
return resp
def _activate_user(self, email):
'''Look up the activation key for the user, then hit the activate view.
No error checking'''
"""Look up the activation key for the user, then hit the activate view.
No error checking"""
activation_key = registration(email).activation_key
# and now we try to activate
@@ -220,12 +218,6 @@ class ContentStoreTest(TestCase):
'display_name': 'Robot Super Course',
}
self.section_data = {
'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course',
'template' : 'i4x://edx/templates/chapter/Empty',
'display_name': 'Section One',
}
def tearDown(self):
# Make sure you flush out the test modulestore after the end
# of the last test because otherwise on the next run
@@ -262,6 +254,16 @@ class ContentStoreTest(TestCase):
self.assertEqual(data['ErrMsg'],
'There is already a course defined with the same organization and course number.')
def test_create_course_with_bad_organization(self):
"""Test new course creation - error path for bad organization name"""
self.course_data['org'] = 'University of California, Berkeley'
resp = self.client.post(reverse('create_new_course'), self.course_data)
data = parse_json(resp)
self.assertEqual(resp.status_code, 200)
self.assertEqual(data['ErrMsg'],
"Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.")
def test_course_index_view_with_no_courses(self):
"""Test viewing the index page with no courses"""
# Create a course so there is something to view
@@ -271,20 +273,27 @@ class ContentStoreTest(TestCase):
status_code=200,
html=True)
def test_course_factory(self):
course = CourseFactory.create()
self.assertIsInstance(course, xmodule.course_module.CourseDescriptor)
def test_item_factory(self):
course = CourseFactory.create()
item = ItemFactory.create(parent_location=course.location)
self.assertIsInstance(item, xmodule.seq_module.SequenceDescriptor)
def test_course_index_view_with_course(self):
"""Test viewing the index page with an existing course"""
# Create a course so there is something to view
resp = self.client.post(reverse('create_new_course'), self.course_data)
CourseFactory.create(display_name='Robot Super Educational Course')
resp = self.client.get(reverse('index'))
self.assertContains(resp,
'<span class="class-name">Robot Super Course</span>',
'<span class="class-name">Robot Super Educational Course</span>',
status_code=200,
html=True)
def test_course_overview_view_with_course(self):
"""Test viewing the course overview page with an existing course"""
# Create a course so there is something to view
resp = self.client.post(reverse('create_new_course'), self.course_data)
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
data = {
'org': 'MITx',
@@ -300,8 +309,15 @@ class ContentStoreTest(TestCase):
def test_clone_item(self):
"""Test cloning an item. E.g. creating a new section"""
resp = self.client.post(reverse('create_new_course'), self.course_data)
resp = self.client.post(reverse('clone_item'), self.section_data)
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
section_data = {
'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course',
'template' : 'i4x://edx/templates/chapter/Empty',
'display_name': 'Section One',
}
resp = self.client.post(reverse('clone_item'), section_data)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
@@ -323,3 +339,4 @@ class ContentStoreTest(TestCase):
def test_edit_unit_full(self):
self.check_edit_unit('full')

View File

@@ -1,53 +1,59 @@
from .utils import get_course_location_for_item, get_lms_link_for_item, \
compute_unit_state, get_date_display, UnitState
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image
from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, \
create_all_course_groups, get_user_by_email, add_user_to_course_group, \
remove_user_from_course_group, is_user_in_course_group_role, \
get_users_in_course_group_by_role
from cache_toolbox.core import del_cached_content
from collections import defaultdict
from datetime import datetime
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.context_processors import csrf
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import HttpResponse, Http404, HttpResponseBadRequest, \
HttpResponseForbidden
from django_future.csrf import ensure_csrf_cookie
from external_auth.views import ssl_login_shortcut
from functools import partial
from mitxmako.shortcuts import render_to_response, render_to_string
from path import path
from static_replace import replace_urls
from util.json_request import expect_json
from uuid import uuid4
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from xmodule.exceptions import NotFoundError
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.timeparse import stringify_time
from xmodule.x_module import ModuleSystem
from xmodule_modifiers import replace_static_urls, wrap_xmodule
import json
import logging
import os
import shutil
import sys
import tarfile
import time
from contentstore import course_info_model
from contentstore.utils import get_modulestore
import tarfile
import shutil
from datetime import datetime
from collections import defaultdict
from uuid import uuid4
from path import path
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.core.context_processors import csrf
from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse
from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.x_module import ModuleSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from static_replace import replace_urls
from external_auth.views import ssl_login_shortcut
from mitxmako.shortcuts import render_to_response, render_to_string
from xmodule.modulestore.django import modulestore
from xmodule_modifiers import replace_static_urls, wrap_xmodule
from xmodule.exceptions import NotFoundError
from functools import partial
from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
from auth.authz import is_user_in_course_group_role, get_users_in_course_group_by_role
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState, get_course_for_item
from xmodule.modulestore.xml_importer import import_from_xml
from contentstore.course_info_model import get_course_updates,\
update_course_updates, delete_course_update
from cache_toolbox.core import del_cached_content
from xmodule.timeparse import stringify_time
from contentstore.module_info_model import get_module_info, set_module_info
from cms.djangoapps.models.settings.course_details import CourseDetails,\
CourseSettingsEncoder
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.contentstore.utils import get_modulestore
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
@@ -474,11 +480,20 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
error_msg=exc_info_to_str(sys.exc_info())
).xmodule_constructor(system)(None, None)
module.get_html = wrap_xmodule(
module.get_html,
module,
"xmodule_display.html",
)
# cdodge: Special case
if module.location.category == 'static_tab':
module.get_html = wrap_xmodule(
module.get_html,
module,
"xmodule_tab_display.html",
)
else:
module.get_html = wrap_xmodule(
module.get_html,
module,
"xmodule_display.html",
)
module.get_html = replace_static_urls(
module.get_html,
module.metadata.get('data_dir', module.location.course),
@@ -905,7 +920,8 @@ def course_info(request, org, course, name, provided_id=None):
'active_tab': 'courseinfo-tab',
'context_course': course_module,
'url_base' : "/" + org + "/" + course + "/",
'course_updates' : json.dumps(course_info_model.get_course_updates(location))
'course_updates' : json.dumps(get_course_updates(location)),
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
})
@expect_json
@@ -928,13 +944,38 @@ def course_info_updates(request, org, course, provided_id=None):
real_method = request.method
if request.method == 'GET':
return HttpResponse(json.dumps(course_info_model.get_course_updates(location)), mimetype="application/json")
return HttpResponse(json.dumps(get_course_updates(location)), mimetype="application/json")
elif real_method == 'POST':
return HttpResponse(json.dumps(course_info_model.update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
# new instance (unless django makes PUT a POST): updates are coming as POST. Not sure why.
return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
elif real_method == 'PUT':
return HttpResponse(json.dumps(course_info_model.update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
elif real_method == 'DELETE':
return HttpResponse(json.dumps(course_info_model.delete_course_update(location, request.POST, provided_id)), mimetype="application/json")
return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE
return HttpResponse(json.dumps(delete_course_update(location, request.POST, provided_id)), mimetype="application/json")
@expect_json
@login_required
@ensure_csrf_cookie
def module_info(request, module_location):
location = Location(module_location)
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
if real_method == 'GET':
return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location)), mimetype="application/json")
elif real_method == 'POST' or real_method == 'PUT':
return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json")
else:
return HttpResponseBadRequest
@login_required
@ensure_csrf_cookie
@@ -1079,7 +1120,10 @@ def create_new_course(request):
number = request.POST.get('number')
display_name = request.POST.get('display_name')
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
try:
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
except InvalidLocationError as e:
return HttpResponse(json.dumps({'ErrMsg': "Unable to create course '" + display_name + "'.\n\n" + e.message}))
# see if the course already exists
existing_course = None