diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index ef60a0c1b3..b2ee3bf9c8 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -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" : "
    [
  1. date

    content
  2. ]
"} "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) diff --git a/cms/djangoapps/contentstore/management/commands/xlint.py b/cms/djangoapps/contentstore/management/commands/xlint.py new file mode 100644 index 0000000000..6bc254a1ff --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/xlint.py @@ -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_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) diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py new file mode 100644 index 0000000000..cd07e4556d --- /dev/null +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -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) diff --git a/cms/djangoapps/contentstore/tests/factories.py b/cms/djangoapps/contentstore/tests/factories.py new file mode 100644 index 0000000000..3274477098 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/factories.py @@ -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' \ No newline at end of file diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index b0bb6c86a1..f5ce0b3692 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -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, - 'Robot Super Course', + 'Robot Super Educational Course', 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') + diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index df0b36c920..d2f19802af 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -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 diff --git a/cms/envs/jasmine.py b/cms/envs/jasmine.py index b29e170411..5c9be1cf9c 100644 --- a/cms/envs/jasmine.py +++ b/cms/envs/jasmine.py @@ -12,7 +12,9 @@ LOGGING = get_logger_config(TEST_ROOT / "log", logging_env="dev", tracking_filename="tracking.log", dev_env=True, - debug=True) + debug=True, + local_loglevel='ERROR', + console_loglevel='ERROR') PIPELINE_JS['js-test-source'] = { 'source_filenames': sum([ diff --git a/cms/static/client_templates/course_info_handouts.html b/cms/static/client_templates/course_info_handouts.html new file mode 100644 index 0000000000..958a1c77d6 --- /dev/null +++ b/cms/static/client_templates/course_info_handouts.html @@ -0,0 +1,19 @@ +Edit + +

Course Handouts

+<%if (model.get('data') != null) { %> +
+ <%= model.get('data') %> +
+<% } else {%> +

You have no handouts defined

+<% } %> +
+
+ +
+
+ Save + Cancel +
+
diff --git a/cms/static/client_templates/course_info_update.html b/cms/static/client_templates/course_info_update.html new file mode 100644 index 0000000000..79775db5e3 --- /dev/null +++ b/cms/static/client_templates/course_info_update.html @@ -0,0 +1,29 @@ +
  • + +
    +
    + + + +
    +
    + +
    +
    + + Save + Cancel +
    +
    +
    +
    + Edit + Delete +
    +

    + <%= + updateModel.get('date') %> +

    +
    <%= updateModel.get('content') %>
    +
    +
  • \ No newline at end of file diff --git a/cms/static/client_templates/load_templates.html b/cms/static/client_templates/load_templates.html new file mode 100644 index 0000000000..3ff88d6fe5 --- /dev/null +++ b/cms/static/client_templates/load_templates.html @@ -0,0 +1,14 @@ + + +<%block name="jsextra"> + + + + \ No newline at end of file diff --git a/cms/static/img/delete-icon.png b/cms/static/img/delete-icon.png index 1855a2943d..9c7f65daef 100644 Binary files a/cms/static/img/delete-icon.png and b/cms/static/img/delete-icon.png differ diff --git a/cms/static/img/edit-icon.png b/cms/static/img/edit-icon.png index 2da9551010..748d3d2115 100644 Binary files a/cms/static/img/edit-icon.png and b/cms/static/img/edit-icon.png differ diff --git a/cms/static/img/plus-icon-white.png b/cms/static/img/plus-icon-white.png new file mode 100644 index 0000000000..d2c5263f93 Binary files /dev/null and b/cms/static/img/plus-icon-white.png differ diff --git a/cms/static/js/base.js b/cms/static/js/base.js index d221acb7f2..47835fda66 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -11,6 +11,11 @@ $(document).ready(function() { $body = $('body'); $modal = $('.history-modal'); $modalCover = $(' \ No newline at end of file diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html index 94c5e38260..41dee15a7a 100644 --- a/cms/templates/edit-tabs.html +++ b/cms/templates/edit-tabs.html @@ -20,23 +20,24 @@

    Static Tabs

    -
    -
    -
    -
      - % for id in components: -
    1. - % endfor - -
    2. - - New Tab - -
    3. -
    -
    -
    -
    +
    +
    +

    Here you can add and manage additional pages for your course. These pages will be added to the primary navigation menu alongside Courseware, Course Info, Discussion, etc.

    +
    +
    +
      + % for id in components: +
    1. + % endfor + +
    2. + + New Tab + +
    3. +
    +
    +
    \ No newline at end of file diff --git a/cms/templates/index.html b/cms/templates/index.html index 652acfa0ea..d41bcc23ac 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -22,14 +22,14 @@
    - Save - Cancel + +
    - + <%block name="content">
    diff --git a/cms/templates/overview.html b/cms/templates/overview.html index cc0d7e8e32..a75a27745f 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -12,10 +12,10 @@ <%namespace name="units" file="widgets/units.html" /> <%block name="jsextra"> - - - - + + + + <%block name="header_extras"> @@ -24,7 +24,33 @@
    -

    SaveCancel

    +

    +
    + + +

    + +
    +
    + + + + + + + + + + + + + + + + + <% for src in js_source %> + + <% end %> + + + <% for src in js_specs %> + + <% end %> + + + + + + + + diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index ea17c23bb4..9922b1b8a0 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -347,7 +347,7 @@ class CapaModule(XModule): id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "
    " # now do the substitutions which are filesystem based, e.g. '/static/' prefixes - return self.system.replace_urls(html, self.metadata['data_dir']) + return self.system.replace_urls(html, self.metadata['data_dir'], course_namespace=self.location) def handle_ajax(self, dispatch, get): ''' @@ -451,7 +451,7 @@ class CapaModule(XModule): new_answers = dict() for answer_id in answers: try: - new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.metadata['data_dir'])} + new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.metadata['data_dir'], course_namespace=self.location)} except TypeError: log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id])) new_answer = {answer_id: answers[answer_id]} diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py index a7a76fa242..9badd4b892 100644 --- a/common/lib/xmodule/xmodule/contentstore/content.py +++ b/common/lib/xmodule/xmodule/contentstore/content.py @@ -46,6 +46,11 @@ class StaticContent(object): else: return None + @staticmethod + def get_base_url_path_for_course_assets(loc): + if loc is not None: + return "/c4x/{org}/{course}/asset".format(**loc.dict()) + @staticmethod def get_id_from_location(location): return { 'tag':location.tag, 'org' : location.org, 'course' : location.course, diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py index cdf62fdffc..e3b123ce15 100644 --- a/common/lib/xmodule/xmodule/graders.py +++ b/common/lib/xmodule/xmodule/graders.py @@ -1,6 +1,5 @@ import abc import inspect -import json import logging import random import sys @@ -66,17 +65,27 @@ def grader_from_conf(conf): for subgraderconf in conf: subgraderconf = subgraderconf.copy() weight = subgraderconf.pop("weight", 0) + # NOTE: 'name' used to exist in SingleSectionGrader. We are deprecating SingleSectionGrader + # and converting everything into an AssignmentFormatGrader by adding 'min_count' and + # 'drop_count'. AssignmentFormatGrader does not expect 'name', so if it appears + # in bad_args, go ahead remove it (this causes no errors). Eventually, SingleSectionGrader + # should be completely removed. + name = 'name' try: if 'min_count' in subgraderconf: #This is an AssignmentFormatGrader subgrader_class = AssignmentFormatGrader - elif 'name' in subgraderconf: + elif name in subgraderconf: #This is an SingleSectionGrader subgrader_class = SingleSectionGrader else: raise ValueError("Configuration has no appropriate grader class.") bad_args = invalid_args(subgrader_class.__init__, subgraderconf) + # See note above concerning 'name'. + if bad_args.issuperset({name}): + bad_args = bad_args - {name} + del subgraderconf[name] if len(bad_args) > 0: log.warning("Invalid arguments for a subgrader: %s", bad_args) for key in bad_args: diff --git a/common/lib/xmodule/xmodule/js/fixtures/problem.html b/common/lib/xmodule/xmodule/js/fixtures/problem.html index f77ece7845..525b4323b7 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/problem.html +++ b/common/lib/xmodule/xmodule/js/fixtures/problem.html @@ -1 +1,7 @@ -
    +
    +
    +
    +
    \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee index 107930c3b1..120a0fad33 100644 --- a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee @@ -8,25 +8,43 @@ describe 'Problem', -> MathJax.Hub.getAllJax.andReturn [@stubbedJax] window.update_schematics = -> + # Load this function from spec/helper.coffee + # Note that if your test fails with a message like: + # 'External request attempted for blah, which is not defined.' + # this msg is coming from the stubRequests function else clause. + jasmine.stubRequests() + + # note that the fixturesPath is set in spec/helper.coffee loadFixtures 'problem.html' + spyOn Logger, 'log' spyOn($.fn, 'load').andCallFake (url, callback) -> $(@).html readFixtures('problem_content.html') callback() - jasmine.stubRequests() describe 'constructor', -> - beforeEach -> - @problem = new Problem 1, "problem_1", "/problem/url/" - it 'set the element', -> - expect(@problem.el).toBe '#problem_1' + it 'set the element from html', -> + @problem999 = new Problem (" +
    +
    +
    +
    + ") + expect(@problem999.element_id).toBe 'problem_999' + + it 'set the element from loadFixtures', -> + @problem1 = new Problem($('.xmodule_display')) + expect(@problem1.element_id).toBe 'problem_1' describe 'bind', -> beforeEach -> spyOn window, 'update_schematics' MathJax.Hub.getAllJax.andReturn [@stubbedJax] - @problem = new Problem 1, "problem_1", "/problem/url/" + @problem = new Problem($('.xmodule_display')) it 'set mathjax typeset', -> expect(MathJax.Hub.Queue).toHaveBeenCalled() @@ -38,7 +56,7 @@ describe 'Problem', -> expect($('section.action input:button')).toHandleWith 'click', @problem.refreshAnswers it 'bind the check button', -> - expect($('section.action input.check')).toHandleWith 'click', @problem.check + expect($('section.action input.check')).toHandleWith 'click', @problem.check_fd it 'bind the reset button', -> expect($('section.action input.reset')).toHandleWith 'click', @problem.reset @@ -60,7 +78,7 @@ describe 'Problem', -> describe 'render', -> beforeEach -> - @problem = new Problem 1, "problem_1", "/problem/url/" + @problem = new Problem($('.xmodule_display')) @bind = @problem.bind spyOn @problem, 'bind' @@ -86,9 +104,13 @@ describe 'Problem', -> it 're-bind the content', -> expect(@problem.bind).toHaveBeenCalled() + describe 'check_fd', -> + xit 'should have specs written for this functionality', -> + expect(false) + describe 'check', -> beforeEach -> - @problem = new Problem 1, "problem_1", "/problem/url/" + @problem = new Problem($('.xmodule_display')) @problem.answers = 'foo=1&bar=2' it 'log the problem_check event', -> @@ -98,30 +120,34 @@ describe 'Problem', -> it 'submit the answer for check', -> spyOn $, 'postWithPrefix' @problem.check() - expect($.postWithPrefix).toHaveBeenCalledWith '/modx/1/problem_check', 'foo=1&bar=2', jasmine.any(Function) + expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_check', + 'foo=1&bar=2', jasmine.any(Function) describe 'when the response is correct', -> it 'call render with returned content', -> - spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> callback(success: 'correct', contents: 'Correct!') + spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> + callback(success: 'correct', contents: 'Correct!') @problem.check() expect(@problem.el.html()).toEqual 'Correct!' describe 'when the response is incorrect', -> it 'call render with returned content', -> - spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> callback(success: 'incorrect', contents: 'Correct!') + spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> + callback(success: 'incorrect', contents: 'Incorrect!') @problem.check() - expect(@problem.el.html()).toEqual 'Correct!' + expect(@problem.el.html()).toEqual 'Incorrect!' describe 'when the response is undetermined', -> it 'alert the response', -> spyOn window, 'alert' - spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> callback(success: 'Number Only!') + spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> + callback(success: 'Number Only!') @problem.check() expect(window.alert).toHaveBeenCalledWith 'Number Only!' describe 'reset', -> beforeEach -> - @problem = new Problem 1, "problem_1", "/problem/url/" + @problem = new Problem($('.xmodule_display')) it 'log the problem_reset event', -> @problem.answers = 'foo=1&bar=2' @@ -131,7 +157,8 @@ describe 'Problem', -> it 'POST to the problem reset page', -> spyOn $, 'postWithPrefix' @problem.reset() - expect($.postWithPrefix).toHaveBeenCalledWith '/modx/1/problem_reset', { id: 1 }, jasmine.any(Function) + expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_reset', + { id: 'i4x://edX/101/problem/Problem1' }, jasmine.any(Function) it 'render the returned content', -> spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> @@ -141,7 +168,7 @@ describe 'Problem', -> describe 'show', -> beforeEach -> - @problem = new Problem 1, "problem_1", "/problem/url/" + @problem = new Problem($('.xmodule_display')) @problem.el.prepend '
    ' describe 'when the answer has not yet shown', -> @@ -150,12 +177,14 @@ describe 'Problem', -> it 'log the problem_show event', -> @problem.show() - expect(Logger.log).toHaveBeenCalledWith 'problem_show', problem: 1 + expect(Logger.log).toHaveBeenCalledWith 'problem_show', + problem: 'i4x://edX/101/problem/Problem1' it 'fetch the answers', -> spyOn $, 'postWithPrefix' @problem.show() - expect($.postWithPrefix).toHaveBeenCalledWith '/modx/1/problem_show', jasmine.any(Function) + expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_show', + jasmine.any(Function) it 'show the answers', -> spyOn($, 'postWithPrefix').andCallFake (url, callback) -> @@ -220,7 +249,7 @@ describe 'Problem', -> describe 'save', -> beforeEach -> - @problem = new Problem 1, "problem_1", "/problem/url/" + @problem = new Problem($('.xmodule_display')) @problem.answers = 'foo=1&bar=2' it 'log the problem_save event', -> @@ -230,7 +259,8 @@ describe 'Problem', -> it 'POST to save problem', -> spyOn $, 'postWithPrefix' @problem.save() - expect($.postWithPrefix).toHaveBeenCalledWith '/modx/1/problem_save', 'foo=1&bar=2', jasmine.any(Function) + expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save', + 'foo=1&bar=2', jasmine.any(Function) it 'alert to the user', -> spyOn window, 'alert' @@ -240,7 +270,7 @@ describe 'Problem', -> describe 'refreshMath', -> beforeEach -> - @problem = new Problem 1, "problem_1", "/problem/url/" + @problem = new Problem($('.xmodule_display')) $('#input_example_1').val 'E=mc^2' @problem.refreshMath target: $('#input_example_1').get(0) @@ -250,7 +280,7 @@ describe 'Problem', -> describe 'updateMathML', -> beforeEach -> - @problem = new Problem 1, "problem_1", "/problem/url/" + @problem = new Problem($('.xmodule_display')) @stubbedJax.root.toMathML.andReturn '' describe 'when there is no exception', -> @@ -270,7 +300,7 @@ describe 'Problem', -> describe 'refreshAnswers', -> beforeEach -> - @problem = new Problem 1, "problem_1", "/problem/url/" + @problem = new Problem($('.xmodule_display')) @problem.el.html '''