diff --git a/.gitignore b/.gitignore index 81d9a57d3c..f24bdb1bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ cms/static/sass/*.css lms/lib/comment_client/python nosetests.xml cover_html/ +.idea/ diff --git a/.gitmodules b/.gitmodules index e69de29bb2..3c8158cf80 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "common/test/phantom-jasmine"] + path = common/test/phantom-jasmine + url = https://github.com/jcarver989/phantom-jasmine.git diff --git a/Gemfile b/Gemfile index 9ad08c7adb..0fe7df217d 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,5 @@ ruby "1.9.3" gem 'rake' gem 'sass', '3.1.15' gem 'bourbon', '~> 1.3.6' +gem 'colorize' +gem 'launchy' diff --git a/brew-formulas.txt b/brew-formulas.txt index 0aed9645d0..b5b555e2a0 100644 --- a/brew-formulas.txt +++ b/brew-formulas.txt @@ -7,3 +7,4 @@ python yuicompressor node graphviz +mysql diff --git a/cms/.coveragerc b/cms/.coveragerc new file mode 100644 index 0000000000..42638feb8f --- /dev/null +++ b/cms/.coveragerc @@ -0,0 +1,13 @@ +# .coveragerc for cms +[run] +data_file = reports/cms/.coverage +source = cms + +[report] +ignore_errors = True + +[html] +directory = reports/cms/cover + +[xml] +output = reports/cms/coverage.xml diff --git a/cms/CHANGELOG.md b/cms/CHANGELOG.md new file mode 100644 index 0000000000..d21d08d23c --- /dev/null +++ b/cms/CHANGELOG.md @@ -0,0 +1,21 @@ +Instructions +============ +For each pull request, add one or more lines to the bottom of the change list. When +code is released to production, change the `Upcoming` entry to todays date, and add +a new block at the bottom of the file. + + Upcoming + -------- + +Change log entries should be targeted at end users. A good place to start is the +user story that instigated the pull request. + + +Changes +======= + +Upcoming +-------- +* Fix: Deleting last component in a unit does not work +* Fix: Unit name is editable when a unit is public +* Fix: Visual feedback inconsistent when saving a unit name change diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index b5dfc3c4fe..b0bb6c86a1 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -182,17 +182,131 @@ TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data' TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') @override_settings(MODULESTORE=TEST_DATA_MODULESTORE) -class EditTestCase(ContentStoreTestCase): - """Check that editing functionality works on example courses""" +class ContentStoreTest(TestCase): def setUp(self): - email = 'edit@test.com' + uname = 'testuser' + email = 'test+courses@edx.org' password = 'foo' - self.create_account('edittest', email, password) - self.activate_user(email) - self.login(email, password) + + # Create the use so we can log them in. + self.user = User.objects.create_user(uname, email, password) + + # Note that we do not actually need to do anything + # for registration if we directly mark them active. + self.user.is_active = True + # Staff has access to view all courses + self.user.is_staff = True + self.user.save() + + # Flush and initialize the module store + # It needs the templates because it creates new records + # by cloning from the template. + # Note that if your test module gets in some weird state + # (though it shouldn't), do this manually + # from the bash shell to drop it: + # $ mongo test_xmodule --eval "db.dropDatabase()" xmodule.modulestore.django._MODULESTORES = {} xmodule.modulestore.django.modulestore().collection.drop() + xmodule.templates.update_templates() + + self.client = Client() + self.client.login(username=uname, password=password) + + self.course_data = { + 'template': 'i4x://edx/templates/course/Empty', + 'org': 'MITx', + 'number': '999', + '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 + # cms/djangoapps/contentstore/__init__.py + # update_templates() will try to update the templates + # via upsert and it sometimes seems to be messing things up. + xmodule.modulestore.django._MODULESTORES = {} + xmodule.modulestore.django.modulestore().collection.drop() + + def test_create_course(self): + """Test new course creation - happy path""" + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + + def test_create_course_duplicate_course(self): + """Test new course creation - error path""" + resp = self.client.post(reverse('create_new_course'), self.course_data) + 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'], 'There is already a course defined with this name.') + + def test_create_course_duplicate_number(self): + """Test new course creation - error path""" + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.course_data['display_name'] = 'Robot Super Course Two' + + 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'], + 'There is already a course defined with the same organization and course number.') + + 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 + resp = self.client.get(reverse('index')) + self.assertContains(resp, + '

My Courses

', + status_code=200, + html=True) + + 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) + resp = self.client.get(reverse('index')) + self.assertContains(resp, + 'Robot Super 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) + + data = { + 'org': 'MITx', + 'course': '999', + 'name': Location.clean('Robot Super Course'), + } + + resp = self.client.get(reverse('course_index', kwargs=data)) + self.assertContains(resp, + 'Robot Super Course', + status_code=200, + html=True) + + 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) + + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertRegexpMatches(data['id'], + '^i4x:\/\/MITx\/999\/chapter\/([0-9]|[a-f]){32}$') def check_edit_unit(self, test_course_name): import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) @@ -208,3 +322,4 @@ class EditTestCase(ContentStoreTestCase): 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 8794404118..63f59821d1 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -66,7 +66,10 @@ log = logging.getLogger(__name__) COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] -DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential'] +DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] + +# cdodge: these are categories which should not be parented, they are detached from the hierarchy +DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] def _modulestore(location): @@ -287,6 +290,7 @@ def edit_unit(request, location): # TODO (cpennington): If we share units between courses, # this will need to change to check permissions correctly so as # to pick the correct parent subsection + containing_subsection_locs = modulestore().get_parent_locations(location) containing_subsection = modulestore().get_item(containing_subsection_locs[0]) @@ -499,7 +503,8 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_ ) module.get_html = replace_static_urls( module.get_html, - module.metadata.get('data_dir', module.location.course) + module.metadata.get('data_dir', module.location.course), + course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None]) ) save_preview_state(request, preview_id, descriptor.location.url(), module.get_instance_state(), module.get_shared_state()) @@ -580,8 +585,11 @@ def save_item(request): if request.POST.get('data') is not None: data = request.POST['data'] store.update_item(item_location, data) - - if request.POST.get('children') is not None: + + # 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 request.POST and request.POST['children'] is not None: children = request.POST['children'] store.update_children(item_location, children) @@ -688,7 +696,9 @@ def clone_item(request): new_item.metadata['display_name'] = display_name _modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata) - _modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) + + if new_item.location.category not in DETACHED_CATEGORIES: + _modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) return HttpResponse(json.dumps({'id': dest_location.url()})) @@ -872,6 +882,24 @@ def edit_static(request, org, course, coursename): def settings(request, org, course, coursename): return render_to_response('settings.html', {}) +def edit_tabs(request, org, course, coursename): + location = ['i4x', org, course, 'course', coursename] + course_item = modulestore().get_item(location) + static_tabs_loc = Location('i4x', org, course, 'static_tab', None) + + static_tabs = modulestore('direct').get_items(static_tabs_loc) + + components = [ + static_tab.location.url() + for static_tab + in static_tabs + ] + + return render_to_response('edit-tabs.html', { + 'active_tab': 'pages', + 'context_course':course_item, + 'components': components + }) def not_found(request): return render_to_response('error.html', {'error': '404'}) @@ -977,6 +1005,17 @@ def create_new_course(request): # set a default start date to now new_course.metadata['start'] = stringify_time(time.gmtime()) + # set up the default tabs + # I've added this because when we add static tabs, the LMS either expects a None for the tabs list or + # at least a list populated with the minimal times + # @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better + # place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here + new_course.tabs = [{"type": "courseware"}, + {"type": "course_info", "name": "Course Info"}, + {"type": "discussion", "name": "Discussion"}, + {"type": "wiki", "name": "Wiki"}, + {"type": "progress", "name": "Progress"}] + modulestore('direct').update_metadata(new_course.location.url(), new_course.own_metadata) create_all_course_groups(request.user, new_course.location) @@ -1001,7 +1040,8 @@ def import_course(request, org, course, name): data_root = path(settings.GITHUB_REPO_ROOT) - course_dir = data_root / "{0}-{1}-{2}".format(org, course, name) + course_subdir = "{0}-{1}-{2}".format(org, course, name) + course_dir = data_root / course_subdir if not course_dir.isdir(): os.mkdir(course_dir) @@ -1036,18 +1076,8 @@ def import_course(request, org, course, name): for fname in os.listdir(r): shutil.move(r/fname, course_dir) - with open(course_dir / 'course.xml', 'r') as course_file: - course_data = etree.parse(course_file, parser=edx_xml_parser) - course_data_root = course_data.getroot() - course_data_root.set('org', org) - course_data_root.set('course', course) - course_data_root.set('url_name', name) - - with open(course_dir / 'course.xml', 'w') as course_file: - course_data.write(course_file) - module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT, - [course_dir], load_error_modules=False, static_content_store=contentstore()) + [course_subdir], load_error_modules=False, static_content_store=contentstore(), target_location_namespace = Location(location)) # we can blow this away when we're done importing. shutil.rmtree(course_dir) diff --git a/cms/envs/common.py b/cms/envs/common.py index b04c423732..4b4b69ad39 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -35,6 +35,7 @@ MITX_FEATURES = { 'ENABLE_DISCUSSION_SERVICE': False, 'AUTH_USE_MIT_CERTIFICATES' : False, } +ENABLE_JASMINE = False # needed to use lms student app GENERATE_RANDOM_USER_CREDENTIALS = False @@ -68,9 +69,7 @@ MAKO_TEMPLATES['main'] = [ for namespace, template_dirs in lms.envs.common.MAKO_TEMPLATES.iteritems(): MAKO_TEMPLATES['lms.' + namespace] = template_dirs -TEMPLATE_DIRS = ( - PROJECT_ROOT / "templates", -) +TEMPLATE_DIRS = MAKO_TEMPLATES['main'] MITX_ROOT_URL = '' @@ -88,10 +87,6 @@ TEMPLATE_CONTEXT_PROCESSORS = ( LMS_BASE = None -################################# Jasmine ################################### -JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' - - #################### CAPA External Code Evaluation ############################# XQUEUE_INTERFACE = { 'url': 'http://localhost:8888', @@ -289,7 +284,4 @@ INSTALLED_APPS = ( # For asset pipelining 'pipeline', 'staticfiles', - - # For testing - 'django_jasmine', ) diff --git a/cms/envs/jasmine.py b/cms/envs/jasmine.py new file mode 100644 index 0000000000..b29e170411 --- /dev/null +++ b/cms/envs/jasmine.py @@ -0,0 +1,36 @@ +""" +This configuration is used for running jasmine tests +""" + +from .test import * +from logsettings import get_logger_config + +ENABLE_JASMINE = True +DEBUG = True + +LOGGING = get_logger_config(TEST_ROOT / "log", + logging_env="dev", + tracking_filename="tracking.log", + dev_env=True, + debug=True) + +PIPELINE_JS['js-test-source'] = { + 'source_filenames': sum([ + pipeline_group['source_filenames'] + for group_name, pipeline_group + in PIPELINE_JS.items() + if group_name != 'spec' + ], []), + 'output_filename': 'js/cms-test-source.js' +} + +PIPELINE_JS['spec'] = { + 'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.coffee')), + 'output_filename': 'js/cms-spec.js' +} + +JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' + +STATICFILES_DIRS.append(COMMON_ROOT / 'test' / 'phantom-jasmine' / 'lib') + +INSTALLED_APPS += ('django_jasmine', ) diff --git a/cms/envs/test.py b/cms/envs/test.py index 217ed0e573..cb84f32ff1 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -14,13 +14,14 @@ from path import path # Nose Test Runner INSTALLED_APPS += ('django_nose',) -NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html', '--cover-inclusive'] -for app in os.listdir(PROJECT_ROOT / 'djangoapps'): - NOSE_ARGS += ['--cover-package', app] +NOSE_ARGS = ['--with-xunit'] TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' TEST_ROOT = path('test_root') +# Makes the tests run much faster... +SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead + # Want static files in the same dir for running on jenkins. STATIC_ROOT = TEST_ROOT / "staticfiles" @@ -98,3 +99,10 @@ CACHES = { 'KEY_FUNCTION': 'util.memcache.safe_key', } } + +################### Make tests faster +#http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/ +PASSWORD_HASHERS = ( + 'django.contrib.auth.hashers.SHA1PasswordHasher', + 'django.contrib.auth.hashers.MD5PasswordHasher', +) \ No newline at end of file diff --git a/cms/static/coffee/spec/main_spec.coffee b/cms/static/coffee/spec/main_spec.coffee index 72800cec7f..8b2fa52866 100644 --- a/cms/static/coffee/spec/main_spec.coffee +++ b/cms/static/coffee/spec/main_spec.coffee @@ -8,72 +8,6 @@ describe "CMS", -> it "should initialize Views", -> expect(CMS.Views).toBeDefined() - describe "start", -> - beforeEach -> - @element = $("
") - spyOn(CMS.Views, "Course").andReturn(jasmine.createSpyObj("Course", ["render"])) - CMS.start(@element) - - it "create the Course", -> - expect(CMS.Views.Course).toHaveBeenCalledWith(el: @element) - expect(CMS.Views.Course().render).toHaveBeenCalled() - - describe "view stack", -> - beforeEach -> - @currentView = jasmine.createSpy("currentView") - CMS.viewStack = [@currentView] - - describe "replaceView", -> - beforeEach -> - @newView = jasmine.createSpy("newView") - CMS.on("content.show", (@expectedView) =>) - CMS.replaceView(@newView) - - it "replace the views on the viewStack", -> - expect(CMS.viewStack).toEqual([@newView]) - - it "trigger content.show on CMS", -> - expect(@expectedView).toEqual(@newView) - - describe "pushView", -> - beforeEach -> - @newView = jasmine.createSpy("newView") - CMS.on("content.show", (@expectedView) =>) - CMS.pushView(@newView) - - it "push new view onto viewStack", -> - expect(CMS.viewStack).toEqual([@currentView, @newView]) - - it "trigger content.show on CMS", -> - expect(@expectedView).toEqual(@newView) - - describe "popView", -> - it "remove the current view from the viewStack", -> - CMS.popView() - expect(CMS.viewStack).toEqual([]) - - describe "when there's no view on the viewStack", -> - beforeEach -> - CMS.viewStack = [@currentView] - CMS.on("content.hide", => @eventTriggered = true) - CMS.popView() - - it "trigger content.hide on CMS", -> - expect(@eventTriggered).toBeTruthy - - describe "when there's previous view on the viewStack", -> - beforeEach -> - @parentView = jasmine.createSpyObj("parentView", ["delegateEvents"]) - CMS.viewStack = [@parentView, @currentView] - CMS.on("content.show", (@expectedView) =>) - CMS.popView() - - it "trigger content.show with the previous view on CMS", -> - expect(@expectedView).toEqual @parentView - - it "re-bind events on the view", -> - expect(@parentView.delegateEvents).toHaveBeenCalled() - describe "main helper", -> beforeEach -> @previousAjaxSettings = $.extend(true, {}, $.ajaxSettings) diff --git a/cms/static/coffee/spec/models/module_spec.coffee b/cms/static/coffee/spec/models/module_spec.coffee index 8fd552d93c..5fd447539f 100644 --- a/cms/static/coffee/spec/models/module_spec.coffee +++ b/cms/static/coffee/spec/models/module_spec.coffee @@ -3,75 +3,4 @@ describe "CMS.Models.Module", -> expect(new CMS.Models.Module().url).toEqual("/save_item") it "set the correct default", -> - expect(new CMS.Models.Module().defaults).toEqual({data: ""}) - - describe "loadModule", -> - describe "when the module exists", -> - beforeEach -> - @fakeModule = jasmine.createSpy("fakeModuleObject") - window.FakeModule = jasmine.createSpy("FakeModule").andReturn(@fakeModule) - @module = new CMS.Models.Module(type: "FakeModule") - @stubDiv = $('
') - @stubElement = $('
') - @stubElement.data('type', "FakeModule") - - @stubDiv.append(@stubElement) - @module.loadModule(@stubDiv) - - afterEach -> - window.FakeModule = undefined - - it "initialize the module", -> - expect(window.FakeModule).toHaveBeenCalled() - # Need to compare underlying nodes, because jquery selectors - # aren't equal even when they point to the same node. - # http://stackoverflow.com/questions/9505437/how-to-test-jquery-with-jasmine-for-element-id-if-used-as-this - expectedNode = @stubElement[0] - actualNode = window.FakeModule.mostRecentCall.args[0][0] - - expect(actualNode).toEqual(expectedNode) - expect(@module.module).toEqual(@fakeModule) - - describe "when the module does not exists", -> - beforeEach -> - @previousConsole = window.console - window.console = jasmine.createSpyObj("fakeConsole", ["error"]) - @module = new CMS.Models.Module(type: "HTML") - @module.loadModule($("
")) - - afterEach -> - window.console = @previousConsole - - it "print out error to log", -> - expect(window.console.error).toHaveBeenCalled() - expect(window.console.error.mostRecentCall.args[0]).toMatch("^Unable to load") - - - describe "editUrl", -> - it "construct the correct URL based on id", -> - expect(new CMS.Models.Module(id: "i4x://mit.edu/module/html_123").editUrl()) - .toEqual("/edit_item?id=i4x%3A%2F%2Fmit.edu%2Fmodule%2Fhtml_123") - - describe "save", -> - beforeEach -> - spyOn(Backbone.Model.prototype, "save") - @module = new CMS.Models.Module() - - describe "when the module exists", -> - beforeEach -> - @module.module = jasmine.createSpyObj("FakeModule", ["save"]) - @module.module.save.andReturn("module data") - @module.save() - - it "set the data and call save on the module", -> - expect(@module.get("data")).toEqual("\"module data\"") - - it "call save on the backbone model", -> - expect(Backbone.Model.prototype.save).toHaveBeenCalled() - - describe "when the module does not exists", -> - beforeEach -> - @module.save() - - it "call save on the backbone model", -> - expect(Backbone.Model.prototype.save).toHaveBeenCalled() + expect(new CMS.Models.Module().defaults).toEqual(undefined) diff --git a/cms/static/coffee/spec/views/course_spec.coffee b/cms/static/coffee/spec/views/course_spec.coffee deleted file mode 100644 index f6a430ac2d..0000000000 --- a/cms/static/coffee/spec/views/course_spec.coffee +++ /dev/null @@ -1,85 +0,0 @@ -describe "CMS.Views.Course", -> - beforeEach -> - setFixtures """ -
-
-
    -
  1. -
  2. -
-
- """ - CMS.unbind() - - describe "render", -> - beforeEach -> - spyOn(CMS.Views, "Week").andReturn(jasmine.createSpyObj("Week", ["render"])) - new CMS.Views.Course(el: $("#main-section")).render() - - it "create week view for each week",-> - expect(CMS.Views.Week.calls[0].args[0]) - .toEqual({ el: $(".week-one").get(0), height: 101 }) - expect(CMS.Views.Week.calls[1].args[0]) - .toEqual({ el: $(".week-two").get(0), height: 101 }) - - describe "on content.show", -> - beforeEach -> - @view = new CMS.Views.Course(el: $("#main-section")) - @subView = jasmine.createSpyObj("subView", ["render"]) - @subView.render.andReturn(el: "Subview Content") - spyOn(@view, "contentHeight").andReturn(100) - CMS.trigger("content.show", @subView) - - afterEach -> - $("body").removeClass("content") - - it "add content class to body", -> - expect($("body").attr("class")).toEqual("content") - - it "replace content in .main-content", -> - expect($(".main-content")).toHaveHtml("Subview Content") - - it "set height on calendar", -> - expect($(".cal")).toHaveCss(height: "100px") - - it "set minimum height on all sections", -> - expect($("#main-section>section")).toHaveCss(minHeight: "100px") - - describe "on content.hide", -> - beforeEach -> - $("body").addClass("content") - @view = new CMS.Views.Course(el: $("#main-section")) - $(".cal").css(height: 100) - $("#main-section>section").css(minHeight: 100) - CMS.trigger("content.hide") - - afterEach -> - $("body").removeClass("content") - - it "remove content class from body", -> - expect($("body").attr("class")).toEqual("") - - it "remove content from .main-content", -> - expect($(".main-content")).toHaveHtml("") - - it "reset height on calendar", -> - expect($(".cal")).not.toHaveCss(height: "100px") - - it "reset minimum height on all sections", -> - expect($("#main-section>section")).not.toHaveCss(minHeight: "100px") - - describe "maxWeekHeight", -> - it "return maximum height of the week element", -> - @view = new CMS.Views.Course(el: $("#main-section")) - expect(@view.maxWeekHeight()).toEqual(101) - - describe "contentHeight", -> - beforeEach -> - $("body").append($('
').height(100).hide()) - - afterEach -> - $("body>header#test").remove() - - it "return the window height minus the header bar", -> - @view = new CMS.Views.Course(el: $("#main-section")) - expect(@view.contentHeight()).toEqual($(window).height() - 100) diff --git a/cms/static/coffee/spec/views/module_edit_spec.coffee b/cms/static/coffee/spec/views/module_edit_spec.coffee index 067d169bca..5e83ecb42d 100644 --- a/cms/static/coffee/spec/views/module_edit_spec.coffee +++ b/cms/static/coffee/spec/views/module_edit_spec.coffee @@ -1,81 +1,74 @@ describe "CMS.Views.ModuleEdit", -> beforeEach -> - @stubModule = jasmine.createSpyObj("Module", ["editUrl", "loadModule"]) - spyOn($.fn, "load") + @stubModule = jasmine.createSpy("CMS.Models.Module") + @stubModule.id = 'stub-id' + + setFixtures """ -
- save - cancel -
    -
  1. - submodule -
  2. -
+
  • +
    +
    + ${editor} +
    + Save + Cancel
    - """ #" +
    + Edit + Delete +
    + +
    +
    +
    +
  • + """ + spyOn($.fn, 'load').andReturn(@moduleData) + + @moduleEdit = new CMS.Views.ModuleEdit( + el: $(".component") + model: @stubModule + onDelete: jasmine.createSpy() + ) CMS.unbind() - describe "defaults", -> - it "set the correct tagName", -> - expect(new CMS.Views.ModuleEdit(model: @stubModule).tagName).toEqual("section") + describe "class definition", -> + it "sets the correct tagName", -> + expect(@moduleEdit.tagName).toEqual("li") - it "set the correct className", -> - expect(new CMS.Views.ModuleEdit(model: @stubModule).className).toEqual("edit-pane") + it "sets the correct className", -> + expect(@moduleEdit.className).toEqual("component") - describe "view creation", -> - beforeEach -> - @stubModule.editUrl.andReturn("/edit_item?id=stub_module") - new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule) + describe "methods", -> + describe "initialize", -> + beforeEach -> + spyOn(CMS.Views.ModuleEdit.prototype, 'render') + @moduleEdit = new CMS.Views.ModuleEdit( + el: $(".component") + model: @stubModule + onDelete: jasmine.createSpy() + ) - it "load the edit via ajax and pass to the model", -> - expect($.fn.load).toHaveBeenCalledWith("/edit_item?id=stub_module", jasmine.any(Function)) - if $.fn.load.mostRecentCall - $.fn.load.mostRecentCall.args[1]() - expect(@stubModule.loadModule).toHaveBeenCalledWith($("#module-edit").get(0)) + it "renders the module editor", -> + expect(@moduleEdit.render).toHaveBeenCalled() - describe "save", -> - beforeEach -> - @stubJqXHR = jasmine.createSpy("stubJqXHR") - @stubJqXHR.success = jasmine.createSpy("stubJqXHR.success").andReturn(@stubJqXHR) - @stubJqXHR.error = jasmine.createSpy("stubJqXHR.error").andReturn(@stubJqXHR) - @stubModule.save = jasmine.createSpy("stubModule.save").andReturn(@stubJqXHR) - new CMS.Views.ModuleEdit(el: $(".module-edit"), model: @stubModule) - spyOn(window, "alert") - $(".save-update").click() + describe "render", -> + beforeEach -> + spyOn(@moduleEdit, 'loadDisplay') + spyOn(@moduleEdit, 'delegateEvents') + @moduleEdit.render() - it "call save on the model", -> - expect(@stubModule.save).toHaveBeenCalled() + it "loads the module preview and editor via ajax on the view element", -> + expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/preview_component/#{@moduleEdit.model.id}", jasmine.any(Function)) + @moduleEdit.$el.load.mostRecentCall.args[1]() + expect(@moduleEdit.loadDisplay).toHaveBeenCalled() + expect(@moduleEdit.delegateEvents).toHaveBeenCalled() - it "alert user on success", -> - @stubJqXHR.success.mostRecentCall.args[0]() - expect(window.alert).toHaveBeenCalledWith("Your changes have been saved.") + describe "loadDisplay", -> + beforeEach -> + spyOn(XModule, 'loadModule') + @moduleEdit.loadDisplay() - it "alert user on error", -> - @stubJqXHR.error.mostRecentCall.args[0]() - expect(window.alert).toHaveBeenCalledWith("There was an error saving your changes. Please try again.") - - describe "cancel", -> - beforeEach -> - spyOn(CMS, "popView") - @view = new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule) - $(".cancel").click() - - it "pop current view from viewStack", -> - expect(CMS.popView).toHaveBeenCalled() - - describe "editSubmodule", -> - beforeEach -> - @view = new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule) - spyOn(CMS, "pushView") - spyOn(CMS.Views, "ModuleEdit") - .andReturn(@view = jasmine.createSpy("Views.ModuleEdit")) - spyOn(CMS.Models, "Module") - .andReturn(@model = jasmine.createSpy("Models.Module")) - $(".module-edit").click() - - it "push another module editing view into viewStack", -> - expect(CMS.pushView).toHaveBeenCalledWith @view - expect(CMS.Views.ModuleEdit).toHaveBeenCalledWith model: @model - expect(CMS.Models.Module).toHaveBeenCalledWith - id: "i4x://mitx/course/html/module" - type: "html" + it "loads the .xmodule-display inside the module editor", -> + expect(XModule.loadModule).toHaveBeenCalled() + expect(XModule.loadModule.mostRecentCall.args[0]).toBe($('.xmodule_display')) diff --git a/cms/static/coffee/spec/views/module_spec.coffee b/cms/static/coffee/spec/views/module_spec.coffee deleted file mode 100644 index 826263bc41..0000000000 --- a/cms/static/coffee/spec/views/module_spec.coffee +++ /dev/null @@ -1,24 +0,0 @@ -describe "CMS.Views.Module", -> - beforeEach -> - setFixtures """ -
    - edit -
    - """ - - describe "edit", -> - beforeEach -> - @view = new CMS.Views.Module(el: $("#module")) - spyOn(CMS, "replaceView") - spyOn(CMS.Views, "ModuleEdit") - .andReturn(@view = jasmine.createSpy("Views.ModuleEdit")) - spyOn(CMS.Models, "Module") - .andReturn(@model = jasmine.createSpy("Models.Module")) - $(".module-edit").click() - - it "replace the main view with ModuleEdit view", -> - expect(CMS.replaceView).toHaveBeenCalledWith @view - expect(CMS.Views.ModuleEdit).toHaveBeenCalledWith model: @model - expect(CMS.Models.Module).toHaveBeenCalledWith - id: "i4x://mitx/course/html/module" - type: "html" diff --git a/cms/static/coffee/spec/views/week_edit_spec.coffee b/cms/static/coffee/spec/views/week_edit_spec.coffee deleted file mode 100644 index 754474d77f..0000000000 --- a/cms/static/coffee/spec/views/week_edit_spec.coffee +++ /dev/null @@ -1,7 +0,0 @@ -describe "CMS.Views.WeekEdit", -> - describe "defaults", -> - it "set the correct tagName", -> - expect(new CMS.Views.WeekEdit().tagName).toEqual("section") - - it "set the correct className", -> - expect(new CMS.Views.WeekEdit().className).toEqual("edit-pane") diff --git a/cms/static/coffee/spec/views/week_spec.coffee b/cms/static/coffee/spec/views/week_spec.coffee deleted file mode 100644 index d5256b0a57..0000000000 --- a/cms/static/coffee/spec/views/week_spec.coffee +++ /dev/null @@ -1,67 +0,0 @@ -describe "CMS.Views.Week", -> - beforeEach -> - setFixtures """ -
    -
    - - edit -
      -
    • -
    • -
    -
    - """ - CMS.unbind() - - describe "render", -> - beforeEach -> - spyOn(CMS.Views, "Module").andReturn(jasmine.createSpyObj("Module", ["render"])) - $.fn.inlineEdit = jasmine.createSpy("$.fn.inlineEdit") - @view = new CMS.Views.Week(el: $("#week"), height: 100).render() - - it "set the height of the element", -> - expect(@view.el).toHaveCss(height: "100px") - - it "make .editable as inline editor", -> - expect($.fn.inlineEdit.calls[0].object.get(0)) - .toEqual($(".editable").get(0)) - - it "make .editable-test as inline editor", -> - expect($.fn.inlineEdit.calls[1].object.get(0)) - .toEqual($(".editable-textarea").get(0)) - - it "create module subview for each module", -> - expect(CMS.Views.Module.calls[0].args[0]) - .toEqual({ el: $("#module-one").get(0) }) - expect(CMS.Views.Module.calls[1].args[0]) - .toEqual({ el: $("#module-two").get(0) }) - - describe "edit", -> - beforeEach -> - new CMS.Views.Week(el: $("#week"), height: 100).render() - spyOn(CMS, "replaceView") - spyOn(CMS.Views, "WeekEdit") - .andReturn(@view = jasmine.createSpy("Views.WeekEdit")) - $(".week-edit").click() - - it "replace the content with edit week view", -> - expect(CMS.replaceView).toHaveBeenCalledWith @view - expect(CMS.Views.WeekEdit).toHaveBeenCalled() - - describe "on content.show", -> - beforeEach -> - @view = new CMS.Views.Week(el: $("#week"), height: 100).render() - @view.$el.height("") - @view.setHeight() - - it "set the correct height", -> - expect(@view.el).toHaveCss(height: "100px") - - describe "on content.hide", -> - beforeEach -> - @view = new CMS.Views.Week(el: $("#week"), height: 100).render() - @view.$el.height("100px") - @view.resetHeight() - - it "remove height from the element", -> - expect(@view.el).not.toHaveCss(height: "100px") diff --git a/cms/static/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index 57b6d1ae93..8c23d6ac99 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -6,28 +6,6 @@ AjaxPrefix.addAjaxPrefix(jQuery, -> CMS.prefix) prefix: $("meta[name='path_prefix']").attr('content') - viewStack: [] - - start: (el) -> - new CMS.Views.Course(el: el).render() - - replaceView: (view) -> - @viewStack = [view] - CMS.trigger('content.show', view) - - pushView: (view) -> - @viewStack.push(view) - CMS.trigger('content.show', view) - - popView: -> - @viewStack.pop() - if _.isEmpty(@viewStack) - CMS.trigger('content.hide') - else - view = _.last(@viewStack) - CMS.trigger('content.show', view) - view.delegateEvents() - _.extend CMS, Backbone.Events $ -> @@ -41,7 +19,3 @@ $ -> navigator.userAgent.match /iPhone|iPod|iPad/i $('body').addClass 'touch-based-device' if onTouchBasedDevice() - - - CMS.start($('section.main-container')) - diff --git a/cms/static/coffee/src/models/new_module.coffee b/cms/static/coffee/src/models/new_module.coffee deleted file mode 100644 index 58a109225e..0000000000 --- a/cms/static/coffee/src/models/new_module.coffee +++ /dev/null @@ -1,5 +0,0 @@ -class CMS.Models.NewModule extends Backbone.Model - url: '/clone_item' - - newUrl: -> - "/new_item?#{$.param(parent_location: @get('parent_location'))}" diff --git a/cms/static/coffee/src/views/course.coffee b/cms/static/coffee/src/views/course.coffee deleted file mode 100644 index 2a5a012c07..0000000000 --- a/cms/static/coffee/src/views/course.coffee +++ /dev/null @@ -1,28 +0,0 @@ -class CMS.Views.Course extends Backbone.View - initialize: -> - CMS.on('content.show', @showContent) - CMS.on('content.hide', @hideContent) - - render: -> - @$('#weeks > li').each (index, week) => - new CMS.Views.Week(el: week, height: @maxWeekHeight()).render() - return @ - - showContent: (subview) => - $('body').addClass('content') - @$('.main-content').html(subview.render().el) - @$('.cal').css height: @contentHeight() - @$('>section').css minHeight: @contentHeight() - - hideContent: => - $('body').removeClass('content') - @$('.main-content').empty() - @$('.cal').css height: '' - @$('>section').css minHeight: '' - - maxWeekHeight: -> - weekElementBorderSize = 1 - _.max($('#weeks > li').map -> $(this).height()) + weekElementBorderSize - - contentHeight: -> - $(window).height() - $('body>header').outerHeight() diff --git a/cms/static/coffee/src/views/module.coffee b/cms/static/coffee/src/views/module.coffee deleted file mode 100644 index 1b9e39e8c2..0000000000 --- a/cms/static/coffee/src/views/module.coffee +++ /dev/null @@ -1,14 +0,0 @@ -class CMS.Views.Module extends Backbone.View - events: - "click .module-edit": "edit" - - edit: (event) => - event.preventDefault() - previewType = @$el.data('preview-type') - moduleType = @$el.data('type') - CMS.replaceView new CMS.Views.ModuleEdit - model: new CMS.Models.Module - id: @$el.data('id') - type: if moduleType == 'None' then null else moduleType - previewType: if previewType == 'None' then null else previewType - diff --git a/cms/static/coffee/src/views/module_add.coffee b/cms/static/coffee/src/views/module_add.coffee deleted file mode 100644 index f379174c77..0000000000 --- a/cms/static/coffee/src/views/module_add.coffee +++ /dev/null @@ -1,26 +0,0 @@ -class CMS.Views.ModuleAdd extends Backbone.View - tagName: 'section' - className: 'add-pane' - - events: - 'click .cancel': 'cancel' - 'click .save': 'save' - - initialize: -> - @$el.load @model.newUrl() - - save: (event) -> - event.preventDefault() - @model.save({ - name: @$el.find('.name').val() - template: $(event.target).data('template-id') - }, { - success: -> CMS.popView() - error: -> alert('Create failed') - }) - - cancel: (event) -> - event.preventDefault() - CMS.popView() - - diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee new file mode 100644 index 0000000000..34d86a3051 --- /dev/null +++ b/cms/static/coffee/src/views/tabs.coffee @@ -0,0 +1,54 @@ +class CMS.Views.TabsEdit extends Backbone.View + events: + 'click .new-tab': 'addNewTab' + + initialize: => + @$('.component').each((idx, element) => + new CMS.Views.ModuleEdit( + el: element, + onDelete: @deleteTab, + model: new CMS.Models.Module( + id: $(element).data('id'), + ) + ) + ) + + @$('.components').sortable( + handle: '.drag-handle' + update: (event, ui) => alert 'not yet implemented!' + helper: 'clone' + opacity: '0.5' + placeholder: 'component-placeholder' + forcePlaceholderSize: true + axis: 'y' + items: '> .component' + ) + + addNewTab: (event) => + event.preventDefault() + + editor = new CMS.Views.ModuleEdit( + onDelete: @deleteTab + model: new CMS.Models.Module() + ) + + $('.new-component-item').before(editor.$el) + + editor.cloneTemplate( + @model.get('id'), + 'i4x://edx/templates/static_tab/Empty' + ) + + deleteTab: (event) => + if not confirm 'Are you sure you want to delete this component? This action cannot be undone.' + return + $component = $(event.currentTarget).parents('.component') + $.post('/delete_item', { + id: $component.data('id') + }, => + $component.remove() + ) + + + + diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index 83deb0f549..a250925bf9 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -169,12 +169,21 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View initialize: => @model.on('change:metadata', @render) + @model.on('change:state', @setEnabled) + @setEnabled() @saveName @$spinner = $(''); render: => @$('.unit-display-name-input').val(@model.get('metadata').display_name) + setEnabled: => + disabled = @model.get('state') == 'public' + if disabled + @$('.unit-display-name-input').attr('disabled', true) + else + @$('.unit-display-name-input').removeAttr('disabled') + saveName: => # Treat the metadata dictionary as immutable metadata = $.extend({}, @model.get('metadata')) @@ -191,6 +200,7 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View 'margin-top': '-10px' }); inputField.after(@$spinner); + @$spinner.fadeIn(10) # save the name after a slight delay if @timer diff --git a/cms/static/coffee/src/views/week.coffee b/cms/static/coffee/src/views/week.coffee deleted file mode 100644 index e2b5a50d59..0000000000 --- a/cms/static/coffee/src/views/week.coffee +++ /dev/null @@ -1,32 +0,0 @@ -class CMS.Views.Week extends Backbone.View - events: - 'click .week-edit': 'edit' - 'click .new-module': 'new' - - initialize: -> - CMS.on('content.show', @resetHeight) - CMS.on('content.hide', @setHeight) - - render: -> - @setHeight() - @$('.editable').inlineEdit() - @$('.editable-textarea').inlineEdit(control: 'textarea') - @$('.modules .module').each -> - new CMS.Views.Module(el: this).render() - return @ - - edit: (event) -> - event.preventDefault() - CMS.replaceView(new CMS.Views.WeekEdit()) - - setHeight: => - @$el.height(@options.height) - - resetHeight: => - @$el.height('') - - new: (event) => - event.preventDefault() - CMS.replaceView new CMS.Views.ModuleAdd - model: new CMS.Models.NewModule - parent_location: @$el.data('id') diff --git a/cms/static/coffee/src/views/week_edit.coffee b/cms/static/coffee/src/views/week_edit.coffee deleted file mode 100644 index 3082bc9fe2..0000000000 --- a/cms/static/coffee/src/views/week_edit.coffee +++ /dev/null @@ -1,3 +0,0 @@ -class CMS.Views.WeekEdit extends Backbone.View - tagName: 'section' - className: 'edit-pane' diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 83e2188691..900ef3b697 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -28,7 +28,7 @@ {{uploadDate}} - + @@ -47,7 +47,7 @@ Name Date Added - Embed + URL @@ -68,7 +68,7 @@ ${asset['uploadDate']} - '> + % endfor diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html new file mode 100644 index 0000000000..94c5e38260 --- /dev/null +++ b/cms/templates/edit-tabs.html @@ -0,0 +1,42 @@ +<%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> +<%block name="title">Tabs +<%block name="bodyclass">static-pages + +<%block name="jsextra"> + + + +<%block name="content"> +
    +
    +
    +

    Static Tabs

    +
    +
    +
    +
    +
      + % 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 e195daaed0..652acfa0ea 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -61,7 +61,4 @@
    - - - diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 2b9b2c7884..877f03533c 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -10,7 +10,7 @@ ${context_course.display_name}
    • Courseware
    • -
    • Pages
    • +
    • Tabs
    • Assets
    • Users
    • Import
    • @@ -24,6 +24,6 @@ % else: Log in % endif -
    +
    diff --git a/cms/urls.py b/cms/urls.py index 620460ad35..f23a2116fe 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -36,6 +36,7 @@ urlpatterns = ('', 'contentstore.views.remove_user', name='remove_user'), url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.static_pages', name='static_pages'), url(r'^edit_static/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_static', name='edit_static'), + url(r'^edit_tabs/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'), url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', 'contentstore.views.asset_index', name='asset_index'), url(r'^settings/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.settings', name='settings'), @@ -69,7 +70,7 @@ urlpatterns += ( ) -if settings.DEBUG: +if settings.ENABLE_JASMINE: ## Jasmine urlpatterns = urlpatterns + (url(r'^_jasmine/', include('django_jasmine.urls')),) diff --git a/common/djangoapps/mitxmako/shortcuts.py b/common/djangoapps/mitxmako/shortcuts.py index ba22f2db20..181d3befd5 100644 --- a/common/djangoapps/mitxmako/shortcuts.py +++ b/common/djangoapps/mitxmako/shortcuts.py @@ -42,7 +42,7 @@ def render_to_string(template_name, dictionary, context=None, namespace='main'): context_dictionary.update(context) # fetch and render template template = middleware.lookup[namespace].get_template(template_name) - return template.render(**context_dictionary) + return template.render_unicode(**context_dictionary) def render_to_response(template_name, dictionary, context_instance=None, namespace='main', **kwargs): diff --git a/common/djangoapps/pipeline_mako/templates/static_content.html b/common/djangoapps/pipeline_mako/templates/static_content.html index c153da22fe..302d4d7aa5 100644 --- a/common/djangoapps/pipeline_mako/templates/static_content.html +++ b/common/djangoapps/pipeline_mako/templates/static_content.html @@ -3,8 +3,7 @@ from staticfiles.storage import staticfiles_storage from pipeline_mako import compressed_css, compressed_js %> -<%def name='url(file)'> -<% +<%def name='url(file)'><% try: url = staticfiles_storage.url(file) except: diff --git a/common/djangoapps/static_replace.py b/common/djangoapps/static_replace.py index 58e2c8da15..ee63e3cf93 100644 --- a/common/djangoapps/static_replace.py +++ b/common/djangoapps/static_replace.py @@ -5,6 +5,10 @@ from staticfiles.storage import staticfiles_storage from staticfiles import finders from django.conf import settings +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.xml import XMLModuleStore +from xmodule.contentstore.content import StaticContent + log = logging.getLogger(__name__) def try_staticfiles_lookup(path): @@ -22,7 +26,7 @@ def try_staticfiles_lookup(path): return url -def replace(static_url, prefix=None): +def replace(static_url, prefix=None, course_namespace=None): if prefix is None: prefix = '' else: @@ -41,13 +45,23 @@ def replace(static_url, prefix=None): return static_url.group(0) else: # don't error if file can't be found - url = try_staticfiles_lookup(prefix + static_url.group('rest')) - return "".join([quote, url, quote]) + # cdodge: to support the change over to Mongo backed content stores, lets + # use the utility functions in StaticContent.py + if static_url.group('prefix') == '/static/' and not isinstance(modulestore(), XMLModuleStore): + if course_namespace is None: + raise BaseException('You must pass in course_namespace when remapping static content urls with MongoDB stores') + url = StaticContent.convert_legacy_static_url(static_url.group('rest'), course_namespace) + else: + url = try_staticfiles_lookup(prefix + static_url.group('rest')) + + new_link = "".join([quote, url, quote]) + return new_link -def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/'): + +def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None): def replace_url(static_url): - return replace(static_url, staticfiles_prefix) + return replace(static_url, staticfiles_prefix, course_namespace = course_namespace) return re.sub(r""" (?x) # flags=re.VERBOSE diff --git a/common/djangoapps/student/management/commands/add_to_group.py b/common/djangoapps/student/management/commands/add_to_group.py new file mode 100644 index 0000000000..209d25da85 --- /dev/null +++ b/common/djangoapps/student/management/commands/add_to_group.py @@ -0,0 +1,62 @@ +from optparse import make_option + +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth.models import User, Group + +class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option('--list', + action='store_true', + dest='list', + default=False, + help='List available groups'), + make_option('--create', + action='store_true', + dest='create', + default=False, + help='Create the group if it does not exist'), + make_option('--remove', + action='store_true', + dest='remove', + default=False, + help='Remove the user from the group instead of adding it'), + ) + + args = ' ' + help = 'Add a user to a group' + + def print_groups(self): + print 'Groups available:' + for group in Group.objects.all().distinct(): + print ' ', group.name + + def handle(self, *args, **options): + if options['list']: + self.print_groups() + return + + if len(args) != 2: + raise CommandError('Usage is add_to_group {0}'.format(self.args)) + + name_or_email, group_name = args + + if '@' in name_or_email: + user = User.objects.get(email=name_or_email) + else: + user = User.objects.get(username=name_or_email) + + try: + group = Group.objects.get(name=group_name) + except Group.DoesNotExist: + if options['create']: + group = Group(name=group_name) + group.save() + else: + raise CommandError('Group {} does not exist'.format(group_name)) + + if options['remove']: + user.groups.remove(group) + else: + user.groups.add(group) + + print 'Success!' diff --git a/common/djangoapps/student/management/commands/pearson_export_cdd.py b/common/djangoapps/student/management/commands/pearson_export_cdd.py new file mode 100644 index 0000000000..b10e92d92d --- /dev/null +++ b/common/djangoapps/student/management/commands/pearson_export_cdd.py @@ -0,0 +1,156 @@ +import csv +import uuid +from collections import defaultdict, OrderedDict +from datetime import datetime + +from django.core.management.base import BaseCommand, CommandError + +from student.models import TestCenterUser + +class Command(BaseCommand): + CSV_TO_MODEL_FIELDS = OrderedDict([ + ("ClientCandidateID", "client_candidate_id"), + ("FirstName", "first_name"), + ("LastName", "last_name"), + ("MiddleName", "middle_name"), + ("Suffix", "suffix"), + ("Salutation", "salutation"), + ("Email", "email"), + # Skipping optional fields Username and Password + ("Address1", "address_1"), + ("Address2", "address_2"), + ("Address3", "address_3"), + ("City", "city"), + ("State", "state"), + ("PostalCode", "postal_code"), + ("Country", "country"), + ("Phone", "phone"), + ("Extension", "extension"), + ("PhoneCountryCode", "phone_country_code"), + ("FAX", "fax"), + ("FAXCountryCode", "fax_country_code"), + ("CompanyName", "company_name"), + # Skipping optional field CustomQuestion + ("LastUpdate", "user_updated_at"), # in UTC, so same as what we store + ]) + + args = '' + help = """ + Export user information from TestCenterUser model into a tab delimited + text file with a format that Pearson expects. + """ + def handle(self, *args, **kwargs): + if len(args) < 1: + print Command.help + return + + self.reset_sample_data() + + with open(args[0], "wb") as outfile: + writer = csv.DictWriter(outfile, + Command.CSV_TO_MODEL_FIELDS, + delimiter="\t", + quoting=csv.QUOTE_MINIMAL, + extrasaction='ignore') + writer.writeheader() + for tcu in TestCenterUser.objects.order_by('id'): + record = dict((csv_field, getattr(tcu, model_field)) + for csv_field, model_field + in Command.CSV_TO_MODEL_FIELDS.items()) + record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S") + writer.writerow(record) + + def reset_sample_data(self): + def make_sample(**kwargs): + data = dict((model_field, kwargs.get(model_field, "")) + for model_field in Command.CSV_TO_MODEL_FIELDS.values()) + return TestCenterUser(**data) + + def generate_id(): + return "edX{:012}".format(uuid.uuid4().int % (10**12)) + + # TestCenterUser.objects.all().delete() + + samples = [ + make_sample( + client_candidate_id=generate_id(), + first_name="Jack", + last_name="Doe", + middle_name="C", + address_1="11 Cambridge Center", + address_2="Suite 101", + city="Cambridge", + state="MA", + postal_code="02140", + country="USA", + phone="(617)555-5555", + phone_country_code="1", + user_updated_at=datetime.utcnow() + ), + make_sample( + client_candidate_id=generate_id(), + first_name="Clyde", + last_name="Smith", + middle_name="J", + suffix="Jr.", + salutation="Mr.", + address_1="1 Penny Lane", + city="Honolulu", + state="HI", + postal_code="96792", + country="USA", + phone="555-555-5555", + phone_country_code="1", + user_updated_at=datetime.utcnow() + ), + make_sample( + client_candidate_id=generate_id(), + first_name="Patty", + last_name="Lee", + salutation="Dr.", + address_1="P.O. Box 555", + city="Honolulu", + state="HI", + postal_code="96792", + country="USA", + phone="808-555-5555", + phone_country_code="1", + user_updated_at=datetime.utcnow() + ), + make_sample( + client_candidate_id=generate_id(), + first_name="Jimmy", + last_name="James", + address_1="2020 Palmer Blvd.", + city="Springfield", + state="MA", + postal_code="96792", + country="USA", + phone="917-555-5555", + phone_country_code="1", + extension="2039", + fax="917-555-5556", + fax_country_code="1", + company_name="ACME Traps", + user_updated_at=datetime.utcnow() + ), + make_sample( + client_candidate_id=generate_id(), + first_name="Yeong-Un", + last_name="Seo", + address_1="Duryu, Lotte 101", + address_2="Apt 55", + city="Daegu", + country="KOR", + phone="917-555-5555", + phone_country_code="011", + user_updated_at=datetime.utcnow() + ), + + ] + + for tcu in samples: + tcu.save() + + + \ No newline at end of file diff --git a/common/djangoapps/student/management/commands/pearson_export_ead.py b/common/djangoapps/student/management/commands/pearson_export_ead.py new file mode 100644 index 0000000000..415f0812ae --- /dev/null +++ b/common/djangoapps/student/management/commands/pearson_export_ead.py @@ -0,0 +1,150 @@ +import csv +import uuid +from collections import defaultdict, OrderedDict +from datetime import datetime + +from django.core.management.base import BaseCommand, CommandError + +from student.models import TestCenterUser + +def generate_id(): + return "{:012}".format(uuid.uuid4().int % (10**12)) + +class Command(BaseCommand): + args = '' + help = """ + Export user information from TestCenterUser model into a tab delimited + text file with a format that Pearson expects. + """ + FIELDS = [ + 'AuthorizationTransactionType', + 'AuthorizationID', + 'ClientAuthorizationID', + 'ClientCandidateID', + 'ExamAuthorizationCount', + 'ExamSeriesCode', + 'EligibilityApptDateFirst', + 'EligibilityApptDateLast', + 'LastUpdate', + ] + + def handle(self, *args, **kwargs): + if len(args) < 1: + print Command.help + return + + # self.reset_sample_data() + + with open(args[0], "wb") as outfile: + writer = csv.DictWriter(outfile, + Command.FIELDS, + delimiter="\t", + quoting=csv.QUOTE_MINIMAL, + extrasaction='ignore') + writer.writeheader() + for tcu in TestCenterUser.objects.order_by('id')[:5]: + record = defaultdict( + lambda: "", + AuthorizationTransactionType="Add", + ClientAuthorizationID=generate_id(), + ClientCandidateID=tcu.client_candidate_id, + ExamAuthorizationCount="1", + ExamSeriesCode="6002x001", + EligibilityApptDateFirst="2012/12/15", + EligibilityApptDateLast="2012/12/30", + LastUpdate=datetime.utcnow().strftime("%Y/%m/%d %H:%M:%S") + ) + writer.writerow(record) + + + def reset_sample_data(self): + def make_sample(**kwargs): + data = dict((model_field, kwargs.get(model_field, "")) + for model_field in Command.CSV_TO_MODEL_FIELDS.values()) + return TestCenterUser(**data) + + # TestCenterUser.objects.all().delete() + + samples = [ + make_sample( + client_candidate_id=generate_id(), + first_name="Jack", + last_name="Doe", + middle_name="C", + address_1="11 Cambridge Center", + address_2="Suite 101", + city="Cambridge", + state="MA", + postal_code="02140", + country="USA", + phone="(617)555-5555", + phone_country_code="1", + user_updated_at=datetime.utcnow() + ), + make_sample( + client_candidate_id=generate_id(), + first_name="Clyde", + last_name="Smith", + middle_name="J", + suffix="Jr.", + salutation="Mr.", + address_1="1 Penny Lane", + city="Honolulu", + state="HI", + postal_code="96792", + country="USA", + phone="555-555-5555", + phone_country_code="1", + user_updated_at=datetime.utcnow() + ), + make_sample( + client_candidate_id=generate_id(), + first_name="Patty", + last_name="Lee", + salutation="Dr.", + address_1="P.O. Box 555", + city="Honolulu", + state="HI", + postal_code="96792", + country="USA", + phone="808-555-5555", + phone_country_code="1", + user_updated_at=datetime.utcnow() + ), + make_sample( + client_candidate_id=generate_id(), + first_name="Jimmy", + last_name="James", + address_1="2020 Palmer Blvd.", + city="Springfield", + state="MA", + postal_code="96792", + country="USA", + phone="917-555-5555", + phone_country_code="1", + extension="2039", + fax="917-555-5556", + fax_country_code="1", + company_name="ACME Traps", + user_updated_at=datetime.utcnow() + ), + make_sample( + client_candidate_id=generate_id(), + first_name="Yeong-Un", + last_name="Seo", + address_1="Duryu, Lotte 101", + address_2="Apt 55", + city="Daegu", + country="KOR", + phone="917-555-5555", + phone_country_code="011", + user_updated_at=datetime.utcnow() + ), + + ] + + for tcu in samples: + tcu.save() + + + \ No newline at end of file diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_user.py b/common/djangoapps/student/management/commands/pearson_make_tc_user.py new file mode 100644 index 0000000000..d974c25b6b --- /dev/null +++ b/common/djangoapps/student/management/commands/pearson_make_tc_user.py @@ -0,0 +1,85 @@ +import uuid +from datetime import datetime +from optparse import make_option + +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand, CommandError + +from student.models import TestCenterUser + +class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option( + '--client_candidate_id', + action='store', + dest='client_candidate_id', + help='ID we assign a user to identify them to Pearson' + ), + make_option( + '--first_name', + action='store', + dest='first_name', + ), + make_option( + '--last_name', + action='store', + dest='last_name', + ), + make_option( + '--address_1', + action='store', + dest='address_1', + ), + make_option( + '--city', + action='store', + dest='city', + ), + make_option( + '--state', + action='store', + dest='state', + help='Two letter code (e.g. MA)' + ), + make_option( + '--postal_code', + action='store', + dest='postal_code', + ), + make_option( + '--country', + action='store', + dest='country', + help='Three letter country code (ISO 3166-1 alpha-3), like USA' + ), + make_option( + '--phone', + action='store', + dest='phone', + help='Pretty free-form (parens, spaces, dashes), but no country code' + ), + make_option( + '--phone_country_code', + action='store', + dest='phone_country_code', + help='Phone country code, just "1" for the USA' + ), + ) + args = "" + help = "Create a TestCenterUser entry for a given Student" + + @staticmethod + def is_valid_option(option_name): + base_options = set(option.dest for option in BaseCommand.option_list) + return option_name not in base_options + + + def handle(self, *args, **options): + username = args[0] + print username + + our_options = dict((k, v) for k, v in options.items() + if Command.is_valid_option(k)) + student = User.objects.get(username=username) + student.test_center_user = TestCenterUser(**our_options) + student.test_center_user.save() diff --git a/common/djangoapps/student/management/commands/set_staff.py b/common/djangoapps/student/management/commands/set_staff.py index 5e7df1f585..30d0483f50 100644 --- a/common/djangoapps/student/management/commands/set_staff.py +++ b/common/djangoapps/student/management/commands/set_staff.py @@ -1,37 +1,47 @@ +from optparse import make_option + from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError import re class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option('--unset', + action='store_true', + dest='unset', + default=False, + help='Set is_staff to False instead of True'), + ) - args = '' + args = ' [user|email ...]>' help = """ - This command will set isstaff to true for one or more users. + This command will set is_staff to true for one or more users. Lookup by username or email address, assumes usernames do not look like email addresses. """ - def handle(self, *args, **kwargs): - + def handle(self, *args, **options): if len(args) < 1: - print Command.help - return + raise CommandError('Usage is set_staff {0}'.format(self.args)) for user in args: - if re.match('[^@]+@[^@]+\.[^@]+', user): try: v = User.objects.get(email=user) except: - raise CommandError("User {0} does not exist".format( - user)) + raise CommandError("User {0} does not exist".format(user)) else: try: v = User.objects.get(username=user) except: - raise CommandError("User {0} does not exist".format( - user)) + raise CommandError("User {0} does not exist".format(user)) + + if options['unset']: + v.is_staff = False + else: + v.is_staff = True - v.is_staff = True v.save() + + print 'Success!' diff --git a/common/djangoapps/student/migrations/0020_add_test_center_user.py b/common/djangoapps/student/migrations/0020_add_test_center_user.py new file mode 100644 index 0000000000..e308e2d7e0 --- /dev/null +++ b/common/djangoapps/student/migrations/0020_add_test_center_user.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'TestCenterUser' + db.create_table('student_testcenteruser', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['auth.User'], unique=True)), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)), + ('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), + ('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)), + ('candidate_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)), + ('client_candidate_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('first_name', self.gf('django.db.models.fields.CharField')(max_length=30, db_index=True)), + ('last_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('middle_name', self.gf('django.db.models.fields.CharField')(max_length=30, blank=True)), + ('suffix', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)), + ('salutation', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)), + ('address_1', self.gf('django.db.models.fields.CharField')(max_length=40)), + ('address_2', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)), + ('address_3', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)), + ('city', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('state', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)), + ('postal_code', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=16, blank=True)), + ('country', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)), + ('phone', self.gf('django.db.models.fields.CharField')(max_length=35)), + ('extension', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=8, blank=True)), + ('phone_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)), + ('fax', self.gf('django.db.models.fields.CharField')(max_length=35, blank=True)), + ('fax_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, blank=True)), + ('company_name', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)), + )) + db.send_create_signal('student', ['TestCenterUser']) + + + def backwards(self, orm): + # Deleting model 'TestCenterUser' + db.delete_table('student_testcenteruser') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}), + 'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}), + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}), + 'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}), + 'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}), + 'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}), + 'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}), + 'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.courseenrollment': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 7e8658045e..61c2537399 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -8,7 +8,7 @@ Portal servers that hold the canoncial user information and that user information is replicated to slave Course server pools. Each Course has a set of servers that serves only its content and has users that are relevant only to it. -We replicate the following tables into the Course DBs where the user is +We replicate the following tables into the Course DBs where the user is enrolled. Only the Portal servers should ever write to these models. * UserProfile * CourseEnrollment @@ -41,33 +41,22 @@ import uuid from django.conf import settings from django.contrib.auth.models import User from django.db import models -from django.db.models.signals import post_delete, post_save -from django.dispatch import receiver -from django_countries import CountryField - from django.db.models.signals import post_save from django.dispatch import receiver -from functools import partial - import comment_client as cc -from django_comment_client.models import Role, Permission +from django_comment_client.models import Role -import logging - -from xmodule.modulestore.django import modulestore - -#from cache_toolbox import cache_model, cache_relation log = logging.getLogger(__name__) class UserProfile(models.Model): - """This is where we store all the user demographic fields. We have a + """This is where we store all the user demographic fields. We have a separate table for this rather than extending the built-in Django auth_user. Notes: - * Some fields are legacy ones from the first run of 6.002, from which + * Some fields are legacy ones from the first run of 6.002, from which we imported many users. * Fields like name and address are intentionally open ended, to account for international variations. An unfortunate side-effect is that we @@ -133,6 +122,72 @@ class UserProfile(models.Model): def set_meta(self, js): self.meta = json.dumps(js) +class TestCenterUser(models.Model): + """This is our representation of the User for in-person testing, and + specifically for Pearson at this point. A few things to note: + + * Pearson only supports Latin-1, so we have to make sure that the data we + capture here will work with that encoding. + * While we have a lot of this demographic data in UserProfile, it's much + more free-structured there. We'll try to pre-pop the form with data from + UserProfile, but we'll need to have a step where people who are signing + up re-enter their demographic data into the fields we specify. + * Users are only created here if they register to take an exam in person. + + The field names and lengths are modeled on the conventions and constraints + of Pearson's data import system, including oddities such as suffix having + a limit of 255 while last_name only gets 50. + """ + # Our own record keeping... + user = models.ForeignKey(User, unique=True, default=None) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True, db_index=True) + # user_updated_at happens only when the user makes a change to their data, + # and is something Pearson needs to know to manage updates. Unlike + # updated_at, this will not get incremented when we do a batch data import. + user_updated_at = models.DateTimeField(db_index=True) + + # Unique ID given to us for this User by the Testing Center. It's null when + # we first create the User entry, and is assigned by Pearson later. + candidate_id = models.IntegerField(null=True, db_index=True) + + # Unique ID we assign our user for a the Test Center. + client_candidate_id = models.CharField(max_length=50, db_index=True) + + # Name + first_name = models.CharField(max_length=30, db_index=True) + last_name = models.CharField(max_length=50, db_index=True) + middle_name = models.CharField(max_length=30, blank=True) + suffix = models.CharField(max_length=255, blank=True) + salutation = models.CharField(max_length=50, blank=True) + + # Address + address_1 = models.CharField(max_length=40) + address_2 = models.CharField(max_length=40, blank=True) + address_3 = models.CharField(max_length=40, blank=True) + city = models.CharField(max_length=32, db_index=True) + # state example: HI -- they have an acceptable list that we'll just plug in + # state is required if you're in the US or Canada, but otherwise not. + state = models.CharField(max_length=20, blank=True, db_index=True) + # postal_code required if you're in the US or Canada + postal_code = models.CharField(max_length=16, blank=True, db_index=True) + # country is a ISO 3166-1 alpha-3 country code (e.g. "USA", "CAN", "MNG") + country = models.CharField(max_length=3, db_index=True) + + # Phone + phone = models.CharField(max_length=35) + extension = models.CharField(max_length=8, blank=True, db_index=True) + phone_country_code = models.CharField(max_length=3, db_index=True) + fax = models.CharField(max_length=35, blank=True) + # fax_country_code required *if* fax is present. + fax_country_code = models.CharField(max_length=3, blank=True) + + # Company + company_name = models.CharField(max_length=50, blank=True) + + @property + def email(self): + return self.user.email ## TODO: Should be renamed to generic UserGroup, and possibly # Given an optional field for type of group diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index b2fcc73ca3..78ade4152c 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -19,16 +19,19 @@ from django.core.context_processors import csrf from django.core.mail import send_mail from django.core.validators import validate_email, validate_slug, ValidationError from django.db import IntegrityError -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, HttpResponseForbidden, Http404 from django.shortcuts import redirect from mitxmako.shortcuts import render_to_response, render_to_string from bs4 import BeautifulSoup from django.core.cache import cache -from django_future.csrf import ensure_csrf_cookie +from django_future.csrf import ensure_csrf_cookie, csrf_exempt from student.models import (Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment) + +from certificates.models import CertificateStatuses, certificate_status_for_student + from xmodule.course_module import CourseDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.django import modulestore @@ -39,6 +42,8 @@ from collections import namedtuple from courseware.courses import get_courses_by_university from courseware.access import has_access +from statsd import statsd + log = logging.getLogger("mitx.student") Article = namedtuple('Article', 'title url author image deck publication publish_date') @@ -141,11 +146,20 @@ def dashboard(request): show_courseware_links_for = frozenset(course.id for course in courses if has_access(request.user, course, 'load')) + # TODO: workaround to not have to zip courses and certificates in the template + # since before there is a migration to certificates + if settings.MITX_FEATURES.get('CERTIFICATES_ENABLED'): + cert_statuses = { course.id: certificate_status_for_student(request.user, course.id) for course in courses} + else: + cert_statuses = {} + context = {'courses': courses, 'message': message, 'staff_access': staff_access, 'errored_courses': errored_courses, - 'show_courseware_links_for' : show_courseware_links_for} + 'show_courseware_links_for' : show_courseware_links_for, + 'cert_statuses': cert_statuses, + } return render_to_response('dashboard.html', context) @@ -205,6 +219,12 @@ def change_enrollment(request): 'error': 'enrollment in {} not allowed at this time' .format(course.display_name)} + org, course_num, run=course_id.split("/") + statsd.increment("common.student.enrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) + enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) return {'success': True} @@ -212,6 +232,13 @@ def change_enrollment(request): try: enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id) enrollment.delete() + + org, course_num, run=course_id.split("/") + statsd.increment("common.student.unenrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) + return {'success': True} except CourseEnrollment.DoesNotExist: return {'success': False, 'error': 'You are not enrolled for this course.'} @@ -261,10 +288,12 @@ def login_user(request, error=""): try_change_enrollment(request) + statsd.increment("common.student.successful_login") + return HttpResponse(json.dumps({'success': True})) - + log.warning("Login failed - Account not active for user {0}, resending activation".format(username)) - + reactivation_email_for_user(user) not_activated_msg = "This account has not been activated. We have " + \ "sent another activation message. Please check your " + \ @@ -467,6 +496,8 @@ def create_account(request, post_override=None): login_user.is_active = True login_user.save() + statsd.increment("common.student.account_created") + js = {'success': True} return HttpResponse(json.dumps(js), mimetype="application/json") @@ -522,9 +553,9 @@ def password_reset(request): ''' Attempts to send a password reset e-mail. ''' if request.method != "POST": raise Http404 - + # By default, Django doesn't allow Users with is_active = False to reset their passwords, - # but this bites people who signed up a long time ago, never activated, and forgot their + # but this bites people who signed up a long time ago, never activated, and forgot their # password. So for their sake, we'll auto-activate a user for whome password_reset is called. try: user = User.objects.get(email=request.POST['email']) @@ -532,7 +563,7 @@ def password_reset(request): user.save() except: log.exception("Tried to auto-activate user to enable password reset, but failed.") - + form = PasswordResetForm(request.POST) if form.is_valid(): form.save(use_https = request.is_secure(), @@ -570,7 +601,7 @@ def reactivation_email_for_user(user): res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) return HttpResponse(json.dumps({'success': True})) - + @ensure_csrf_cookie def change_email_request(request): @@ -745,8 +776,8 @@ def accept_name_change_by_id(id): @ensure_csrf_cookie def accept_name_change(request): - ''' JSON: Name change process. Course staff clicks 'accept' on a given name change - + ''' JSON: Name change process. Course staff clicks 'accept' on a given name change + We used this during the prototype but now we simply record name changes instead of manually approving them. Still keeping this around in case we want to go back to this approval method. @@ -755,3 +786,23 @@ def accept_name_change(request): raise Http404 return accept_name_change_by_id(int(request.POST['id'])) + +# TODO: This is a giant kludge to give Pearson something to test against ASAP. +# Will need to get replaced by something that actually ties into TestCenterUser +@csrf_exempt +def test_center_login(request): + if not settings.MITX_FEATURES.get('ENABLE_PEARSON_HACK_TEST'): + raise Http404 + + client_candidate_id = request.POST.get("clientCandidateID") + # registration_id = request.POST.get("registrationID") + exit_url = request.POST.get("exitURL") + error_url = request.POST.get("errorURL") + + if client_candidate_id == "edX003671291147": + user = authenticate(username=settings.PEARSON_TEST_USER, + password=settings.PEARSON_TEST_PASSWORD) + login(request, user) + return redirect('/courses/MITx/6.002x/2012_Fall/courseware/Final_Exam/Final_Exam_Fall_2012/') + else: + return HttpResponseForbidden() diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 9473dfe26b..cda3d013cd 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -50,7 +50,7 @@ def replace_course_urls(get_html, course_id): return replace_urls(get_html(), staticfiles_prefix='/courses/'+course_id, replace_prefix='/course/') return _get_html -def replace_static_urls(get_html, prefix): +def replace_static_urls(get_html, prefix, course_namespace=None): """ Updates the supplied module with a new get_html function that wraps the old get_html function and substitutes urls of the form /static/... @@ -59,7 +59,7 @@ def replace_static_urls(get_html, prefix): @wraps(get_html) def _get_html(): - return replace_urls(get_html(), staticfiles_prefix=prefix) + return replace_urls(get_html(), staticfiles_prefix=prefix, course_namespace = course_namespace) return _get_html diff --git a/common/lib/capa/.coveragerc b/common/lib/capa/.coveragerc new file mode 100644 index 0000000000..6af3218f75 --- /dev/null +++ b/common/lib/capa/.coveragerc @@ -0,0 +1,13 @@ +# .coveragerc for common/lib/capa +[run] +data_file = reports/common/lib/capa/.coverage +source = common/lib/capa + +[report] +ignore_errors = True + +[html] +directory = reports/common/lib/capa/cover + +[xml] +output = reports/common/lib/capa/coverage.xml diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index d868e66ec5..451891d067 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -32,10 +32,13 @@ from xml.sax.saxutils import unescape import chem import chem.chemcalc +import chem.chemtools + import calc from correctmap import CorrectMap import eia import inputtypes +import customrender from util import contextualize_text, convert_files_to_filenames import xqueue_interface @@ -45,22 +48,8 @@ import responsetypes # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) -# Different ways students can input code -entry_types = ['textline', - 'schematic', - 'textbox', - 'imageinput', - 'optioninput', - 'choicegroup', - 'radiogroup', - 'checkboxgroup', - 'filesubmission', - 'javascriptinput', - 'crystallography', - 'chemicalequationinput',] - # extra things displayed after "show answers" is pressed -solution_types = ['solution'] +solution_tags = ['solution'] # these get captured as student responses response_properties = ["codeparam", "responseparam", "answer"] @@ -77,7 +66,8 @@ global_context = {'random': random, 'scipy': scipy, 'calc': calc, 'eia': eia, - 'chemcalc': chem.chemcalc} + 'chemcalc': chem.chemcalc, + 'chemtools': chem.chemtools} # These should be removed from HTML output, including all subelements html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"] @@ -305,7 +295,7 @@ class LoncapaProblem(object): answer_map.update(results) # include solutions from ... stanzas - for entry in self.tree.xpath("//" + "|//".join(solution_types)): + for entry in self.tree.xpath("//" + "|//".join(solution_tags)): answer = etree.tostring(entry) if answer: answer_map[entry.get('id')] = contextualize_text(answer, self.context) @@ -483,7 +473,7 @@ class LoncapaProblem(object): problemid = problemtree.get('id') # my ID - if problemtree.tag in inputtypes.registered_input_tags(): + if problemtree.tag in inputtypes.registry.registered_tags(): # If this is an inputtype subtree, let it render itself. status = "unsubmitted" msg = '' @@ -509,7 +499,7 @@ class LoncapaProblem(object): 'hint': hint, 'hintmode': hintmode,}} - input_type_cls = inputtypes.get_class_for_tag(problemtree.tag) + input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag) the_input = input_type_cls(self.system, problemtree, state) return the_input.get_html() @@ -517,9 +507,15 @@ class LoncapaProblem(object): if problemtree in self.responders: return self.responders[problemtree].render_html(self._extract_html) + # let each custom renderer render itself: + if problemtree.tag in customrender.registry.registered_tags(): + renderer_class = customrender.registry.get_class_for_tag(problemtree.tag) + renderer = renderer_class(self.system, problemtree) + return renderer.get_html() + + # otherwise, render children recursively, and copy over attributes tree = etree.Element(problemtree.tag) for item in problemtree: - # render child recursively item_xhtml = self._extract_html(item) if item_xhtml is not None: tree.append(item_xhtml) @@ -556,11 +552,12 @@ class LoncapaProblem(object): response_id += 1 answer_id = 1 + input_tags = inputtypes.registry.registered_tags() inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x - for x in (entry_types + solution_types)]), + for x in (input_tags + solution_tags)]), id=response_id_str) - # assign one answer_id for each entry_type or solution_type + # assign one answer_id for each input type or solution type for entry in inputfields: entry.attrib['response_id'] = str(response_id) entry.attrib['answer_id'] = str(answer_id) diff --git a/common/lib/capa/capa/chem/chemtools.py b/common/lib/capa/capa/chem/chemtools.py new file mode 100644 index 0000000000..ad6633fc00 --- /dev/null +++ b/common/lib/capa/capa/chem/chemtools.py @@ -0,0 +1,206 @@ +"""This module originally includes functions for grading Vsepr problems. + +Also, may be this module is the place for other chemistry-related grade functions. TODO: discuss it. +""" + +import json +import unittest +import itertools + + +def vsepr_parse_user_answer(user_input): + """ + user_input is json generated by vsepr.js from dictionary. + There are must be only two keys in original user_input dictionary: "geometry" and "atoms". + Format: u'{"geometry": "AX3E0","atoms":{"c0": "B","p0": "F","p1": "B","p2": "F"}}' + Order of elements inside "atoms" subdict does not matters. + Return dict from parsed json. + + "Atoms" subdict stores positions of atoms in molecule. + General types of positions: + c0 - central atom + p0..pN - peripheral atoms + a0..aN - axial atoms + e0..eN - equatorial atoms + + Each position is dictionary key, i.e. user_input["atoms"]["c0"] is central atom, user_input["atoms"]["a0"] is one of axial atoms. + + Special position only for AX6 (Octahedral) geometry: + e10, e12 - atom pairs opposite the central atom, + e20, e22 - atom pairs opposite the central atom, + e1 and e2 pairs lying crosswise in equatorial plane. + + In user_input["atoms"] may be only 3 set of keys: + (c0,p0..pN), + (c0, a0..aN, e0..eN), + (c0, a0, a1, e10,e11,e20,e21) - if geometry is AX6. + """ + return json.loads(user_input) + + +def vsepr_build_correct_answer(geometry, atoms): + """ + geometry is string. + atoms is dict of atoms with proper positions. + Example: + + correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"}) + + returns a dictionary composed from input values: + {'geometry': geometry, 'atoms': atoms} + """ + return {'geometry': geometry, 'atoms': atoms} + + +def vsepr_grade(user_input, correct_answer, convert_to_peripheral=False): + """ + This function does comparison between user_input and correct_answer. + + Comparison is successful if all steps are successful: + + 1) geometries are equal + 2) central atoms (index in dictionary 'c0') are equal + 3): + In next steps there is comparing of corresponding subsets of atom positions: equatorial (e0..eN), axial (a0..aN) or peripheral (p0..pN) + + If convert_to_peripheral is True, then axial and equatorial positions are converted to peripheral. + This means that user_input from: + "atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}' after parsing to json + is converted to: + {"c0": "Br", "p0": "(ep)", "p1": "test", "p2": "H", "p3": "H", "p4": "(ep)", "p6": "(ep)"} + i.e. aX and eX -> pX + + So if converted, p subsets are compared, + if not a and e subsets are compared + If all subsets are equal, grade succeeds. + + There is also one special case for AX6 geometry. + In this case user_input["atoms"] contains special 3 symbol keys: e10, e12, e20, and e21. + Correct answer for this geometry can be of 3 types: + 1) c0 and peripheral + 2) c0 and axial and equatorial + 3) c0 and axial and equatorial-subset-1 (e1X) and equatorial-subset-2 (e2X) + + If correct answer is type 1 or 2, then user_input is converted from type 3 to type 2 (or to type 1 if convert_to_peripheral is True) + + If correct_answer is type 3, then we done special case comparison. We have 3 sets of atoms positions both in user_input and correct_answer: axial, eq-1 and eq-2. + Answer will be correct if these sets are equals for one of permutations. For example, if : + user_axial = correct_eq-1 + user_eq-1 = correct-axial + user_eq-2 = correct-eq-2 + + """ + if user_input['geometry'] != correct_answer['geometry']: + return False + + if user_input['atoms']['c0'] != correct_answer['atoms']['c0']: + return False + + if convert_to_peripheral: + # convert user_input from (a,e,e1,e2) to (p) + # correct_answer must be set in (p) using this flag + c0 = user_input['atoms'].pop('c0') + user_input['atoms'] = {'p' + str(i): v for i, v in enumerate(user_input['atoms'].values())} + user_input['atoms']['c0'] = c0 + + # special case for AX6 + if 'e10' in correct_answer['atoms']: # need check e1x, e2x symmetry for AX6.. + a_user = {} + a_correct = {} + for ea_position in ['a', 'e1', 'e2']: # collecting positions: + a_user[ea_position] = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)] + a_correct[ea_position] = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)] + + correct = [sorted(a_correct['a'])] + [sorted(a_correct['e1'])] + [sorted(a_correct['e2'])] + for permutation in itertools.permutations(['a', 'e1', 'e2']): + if correct == [sorted(a_user[permutation[0]])] + [sorted(a_user[permutation[1]])] + [sorted(a_user[permutation[2]])]: + return True + return False + + else: # no need to check e1x,e2x symmetry - convert them to ex + if 'e10' in user_input['atoms']: # e1x exists, it is AX6.. case + e_index = 0 + for k, v in user_input['atoms'].items(): + if len(k) == 3: # e1x + del user_input['atoms'][k] + user_input['atoms']['e' + str(e_index)] = v + e_index += 1 + + # common case + for ea_position in ['p', 'a', 'e']: + # collecting atoms: + a_user = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)] + a_correct = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)] + # print a_user, a_correct + if len(a_user) != len(a_correct): + return False + if sorted(a_user) != sorted(a_correct): + return False + + return True + + +class Test_Grade(unittest.TestCase): + ''' test grade function ''' + + def test_incorrect_geometry(self): + correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX3E0","atoms":{"c0": "B","p0": "F","p1": "B","p2": "F"}}') + self.assertFalse(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_p(self): + correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX4E0","atoms":{"c0": "N","p0": "H","p1": "(ep)","p2": "H", "p3": "H"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_ae(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "test", "a1": "(ep)", "e0": "H", "e1": "H", "e2": "(ep)", "e3": "(ep)"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "H","e20": "(ep)","e21": "(ep)"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_ae_convert_to_p_but_input_not_in_p(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "(ep)", "e3": "(ep)"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}') + self.assertFalse(vsepr_grade(user_answer, correct_answer, convert_to_peripheral=True)) + + def test_correct_answer_ae_convert_to_p(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "p0": "(ep)", "p1": "test", "p2": "H", "p3": "H", "p4": "(ep)", "p6": "(ep)"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer, convert_to_peripheral=True)) + + def test_correct_answer_e1e2_in_a(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "(ep)","a1": "(ep)","e10": "H","e11": "H","e20": "H","e21": "H"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_e1e2_in_e1(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "(ep)","e11": "(ep)","e20": "H","e21": "H"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_correct_answer_e1e2_in_e2(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "H","e11": "H","e20": "(ep)","e21": "(ep)"}}') + self.assertTrue(vsepr_grade(user_answer, correct_answer)) + + def test_incorrect_answer_e1e2(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "(ep)","e11": "H","e20": "H","e21": "(ep)"}}') + self.assertFalse(vsepr_grade(user_answer, correct_answer)) + + def test_incorrect_c0(self): + correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "H", "e3": "(ep)"}) + user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "H","a0": "test","a1": "(ep)","e0": "H","e1": "H","e2": "(ep)","e3": "H"}}') + self.assertFalse(vsepr_grade(user_answer, correct_answer)) + + +def suite(): + + testcases = [Test_Grade] + suites = [] + for testcase in testcases: + suites.append(unittest.TestLoader().loadTestsFromTestCase(testcase)) + return unittest.TestSuite(suites) + +if __name__ == "__main__": + unittest.TextTestRunner(verbosity=2).run(suite()) diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 227f85bc8e..c7386219b1 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -3,7 +3,6 @@ # # Used by responsetypes and capa_problem - class CorrectMap(object): """ Stores map between answer_id and response evaluation result for each question @@ -69,7 +68,7 @@ class CorrectMap(object): correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This means that when the definition of CorrectMap (e.g. its properties) are altered, - an existing correct_map dict not coincide with the newest CorrectMap format as + an existing correct_map dict will not coincide with the newest CorrectMap format as defined by self.set. For graceful migration, feed the contents of each correct map to self.set, rather than diff --git a/common/lib/capa/capa/customrender.py b/common/lib/capa/capa/customrender.py new file mode 100644 index 0000000000..ef1044e8b1 --- /dev/null +++ b/common/lib/capa/capa/customrender.py @@ -0,0 +1,100 @@ +""" +This has custom renderers: classes that know how to render certain problem tags (e.g. and +) to html. + +These tags do not have state, so they just get passed the system (for access to render_template), +and the xml element. +""" + +from registry import TagRegistry + +import logging +import re +import shlex # for splitting quoted strings +import json + +from lxml import etree +import xml.sax.saxutils as saxutils +from registry import TagRegistry + +log = logging.getLogger('mitx.' + __name__) + +registry = TagRegistry() + +#----------------------------------------------------------------------------- +class MathRenderer(object): + tags = ['math'] + + def __init__(self, system, xml): + ''' + Render math using latex-like formatting. + + Examples: + + $\displaystyle U(r)=4 U_0 $ + $r_0$ + + We convert these to [mathjax]...[/mathjax] and [mathjaxinline]...[/mathjaxinline] + + TODO: use shorter tags (but this will require converting problem XML files!) + ''' + self.system = system + self.xml = xml + + mathstr = re.sub('\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text) + mtag = 'mathjax' + if not r'\displaystyle' in mathstr: + mtag += 'inline' + else: + mathstr = mathstr.replace(r'\displaystyle', '') + self.mathstr = mathstr.replace('mathjaxinline]', '%s]' % mtag) + + + def get_html(self): + """ + Return the contents of this tag, rendered to html, as an etree element. + """ + # TODO: why are there nested html tags here?? Why are there html tags at all, in fact? + html = '%s%s' % ( + self.mathstr, saxutils.escape(self.xml.tail)) + try: + xhtml = etree.XML(html) + except Exception as err: + if self.system.DEBUG: + msg = '

    Error %s

    ' % ( + str(err).replace('<', '<')) + msg += ('

    Failed to construct math expression from

    %s

    ' % + html.replace('<', '<')) + msg += "
    " + log.error(msg) + return etree.XML(msg) + else: + raise + return xhtml + + +registry.register(MathRenderer) + +#----------------------------------------------------------------------------- + +class SolutionRenderer(object): + ''' + A solution is just a ... which is given an ID, that is used for displaying an + extended answer (a problem "solution") after "show answers" is pressed. + + Note that the solution content is NOT rendered and returned in the HTML. It is obtained by an + ajax call. + ''' + tags = ['solution'] + + def __init__(self, system, xml): + self.system = system + self.id = xml.get('id') + + def get_html(self): + context = {'id': self.id} + html = self.system.render_template("solutionspan.html", context) + return etree.XML(html) + +registry.register(SolutionRenderer) + diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 0e993c1366..0b2250f98d 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -6,11 +6,9 @@ Module containing the problem elements which render into input objects - textline -- textbox (change this to textarea?) -- schemmatic -- choicegroup -- radiogroup -- checkboxgroup +- textbox (aka codeinput) +- schematic +- choicegroup (aka radiogroup, checkboxgroup) - javascriptinput - imageinput (for clickable image) - optioninput (for option list) @@ -23,63 +21,88 @@ Each input type takes the xml tree as 'element', the previous answer as 'value', graded status as'status' """ -# TODO: rename "state" to "status" for all below. status is currently the answer for the -# problem ID for the input element, but it will turn into a dict containing both the -# answer and any associated message for the problem ID for the input element. +# TODO: make hints do something +# TODO: make all inputtypes actually render msg + +# TODO: remove unused fields (e.g. 'hidden' in a few places) + +# TODO: add validators so that content folks get better error messages. + + +# Possible todo: make inline the default for textlines and other "one-line" inputs. It probably +# makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a +# general css and layout strategy for capa, document it, then implement it. + +from collections import namedtuple +import json import logging +from lxml import etree import re import shlex # for splitting quoted strings -import json +import sys -from lxml import etree -import xml.sax.saxutils as saxutils +from registry import TagRegistry log = logging.getLogger('mitx.' + __name__) ######################################################################### -_TAGS_TO_CLASSES = {} +registry = TagRegistry() -def register_input_class(cls): +class Attribute(object): """ - Register cls as a supported input type. It is expected to have the same constructor as - InputTypeBase, and to define cls.tags as a list of tags that it implements. - - If an already-registered input type has claimed one of those tags, will raise ValueError. - - If there are no tags in cls.tags, will also raise ValueError. + Allows specifying required and optional attributes for input types. """ - # Do all checks and complain before changing any state. - if len(cls.tags) == 0: - raise ValueError("No supported tags for class {0}".format(cls.__name__)) + # want to allow default to be None, but also allow required objects + _sentinel = object() - for t in cls.tags: - if t in _TAGS_TO_CLASSES: - other_cls = _TAGS_TO_CLASSES[t] - if cls == other_cls: - # registering the same class multiple times seems silly, but ok - continue - raise ValueError("Tag {0} already registered by class {1}. Can't register for class {2}" - .format(t, other_cls.__name__, cls.__name__)) + def __init__(self, name, default=_sentinel, transform=None, validate=None, render=True): + """ + Define an attribute - # Ok, should be good to change state now. - for t in cls.tags: - _TAGS_TO_CLASSES[t] = cls + name (str): then name of the attribute--should be alphanumeric (valid for an XML attribute) -def registered_input_tags(): - """ - Get a list of all the xml tags that map to known input types. - """ - return _TAGS_TO_CLASSES.keys() + default (any type): If not specified, this attribute is required. If specified, use this as the default value + if the attribute is not specified. Note that this value will not be transformed or validated. + transform (function str -> any type): If not None, will be called to transform the parsed value into an internal + representation. -def get_class_for_tag(tag): - """ - For any tag in registered_input_tags(), return the corresponding class. Otherwise, will raise KeyError. - """ - return _TAGS_TO_CLASSES[tag] + validate (function str-or-return-type-of-tranform -> unit or exception): If not None, called to validate the + (possibly transformed) value of the attribute. Should raise ValueError with a helpful message if + the value is invalid. + + render (bool): if False, don't include this attribute in the template context. + """ + self.name = name + self.default = default + self.validate = validate + self.transform = transform + self.render = render + + def parse_from_xml(self, element): + """ + Given an etree xml element that should have this attribute, do the obvious thing: + - look for it. raise ValueError if not found and required. + - transform and validate. pass through any exceptions from transform or validate. + """ + val = element.get(self.name) + if self.default == self._sentinel and val is None: + raise ValueError('Missing required attribute {0}.'.format(self.name)) + + if val is None: + # not required, so return default + return self.default + + if self.transform is not None: + val = self.transform(val) + + if self.validate is not None: + self.validate(val) + + return val class InputTypeBase(object): @@ -93,16 +116,18 @@ class InputTypeBase(object): """ Instantiate an InputType class. Arguments: - - system : ModuleSystem instance which provides OS, rendering, and user context. Specifically, must - have a render_template function. + - system : ModuleSystem instance which provides OS, rendering, and user context. + Specifically, must have a render_template function. - xml : Element tree of this Input element - state : a dictionary with optional keys: - * 'value' - * 'id' + * 'value' -- the current value of this input + (what the student entered last time) + * 'id' -- the id of this input, typically + "{problem-location}_{response-num}_{input-num}" * 'status' (answered, unanswered, unsubmitted) * 'feedback' (dictionary containing keys for hints, errors, or other - feedback from previous attempt. Specifically 'message', 'hint', 'hintmode'. If 'hintmode' - is 'always', the hint is always displayed.) + feedback from previous attempt. Specifically 'message', 'hint', + 'hintmode'. If 'hintmode' is 'always', the hint is always displayed.) """ self.xml = xml @@ -132,54 +157,104 @@ class InputTypeBase(object): self.status = state.get('status', 'unanswered') + try: + # Pre-parse and propcess all the declared requirements. + self.process_requirements() + + # Call subclass "constructor" -- means they don't have to worry about calling + # super().__init__, and are isolated from changes to the input constructor interface. + self.setup() + except Exception as err: + # Something went wrong: add xml to message, but keep the traceback + msg = "Error in xml '{x}': {err} ".format(x=etree.tostring(xml), err=str(err)) + raise Exception, msg, sys.exc_info()[2] + + + @classmethod + def get_attributes(cls): + """ + Should return a list of Attribute objects (see docstring there for details). Subclasses should override. e.g. + + return [Attribute('unicorn', True), Attribute('num_dragons', 12, transform=int), ...] + """ + return [] + + + def process_requirements(self): + """ + Subclasses can declare lists of required and optional attributes. This + function parses the input xml and pulls out those attributes. This + isolates most simple input types from needing to deal with xml parsing at all. + + Processes attributes, putting the results in the self.loaded_attributes dictionary. Also creates a set + self.to_render, containing the names of attributes that should be included in the context by default. + """ + # Use local dicts and sets so that if there are exceptions, we don't end up in a partially-initialized state. + loaded = {} + to_render = set() + for a in self.get_attributes(): + loaded[a.name] = a.parse_from_xml(self.xml) + if a.render: + to_render.add(a.name) + + self.loaded_attributes = loaded + self.to_render = to_render + + def setup(self): + """ + InputTypes should override this to do any needed initialization. It is called after the + constructor, so all base attributes will be set. + + If this method raises an exception, it will be wrapped with a message that includes the + problem xml. + """ + pass + + def _get_render_context(self): """ - Abstract method. Subclasses should implement to return the dictionary - of keys needed to render their template. + Should return a dictionary of keys needed to render the template for the input type. (Separate from get_html to faciliate testing of logic separately from the rendering) + + The default implementation gets the following rendering context: basic things like value, id, status, and msg, + as well as everything in self.loaded_attributes, and everything returned by self._extra_context(). + + This means that input types that only parse attributes and pass them to the template get everything they need, + and don't need to override this method. """ - raise NotImplementedError + context = { + 'id': self.id, + 'value': self.value, + 'status': self.status, + 'msg': self.msg, + } + context.update((a, v) for (a, v) in self.loaded_attributes.iteritems() if a in self.to_render) + context.update(self._extra_context()) + return context + + def _extra_context(self): + """ + Subclasses can override this to return extra context that should be passed to their templates for rendering. + + This is useful when the input type requires computing new template variables from the parsed attributes. + """ + return {} def get_html(self): """ Return the html for this input, as an etree element. """ if self.template is None: - raise NotImplementedError("no rendering template specified for class {0}".format(self.__class__)) + raise NotImplementedError("no rendering template specified for class {0}" + .format(self.__class__)) - html = self.system.render_template(self.template, self._get_render_context()) + context = self._get_render_context() + + html = self.system.render_template(self.template, context) return etree.XML(html) -## TODO: Remove once refactor is complete -def make_class_for_render_function(fn): - """ - Take an old-style render function, return a new-style input class. - """ - - class Impl(InputTypeBase): - """ - Inherit all the constructor logic from InputTypeBase... - """ - tags = [fn.__name__] - def get_html(self): - """...delegate to the render function to do the work""" - return fn(self.xml, self.value, self.status, self.system.render_template, self.msg) - - # don't want all the classes to be called Impl (confuses register_input_class). - Impl.__name__ = fn.__name__.capitalize() - return Impl - - -def _reg(fn): - """ - Register an old-style inputtype render function as a new-style subclass of InputTypeBase. - This will go away once converting all input types to the new format is complete. (TODO) - """ - register_input_class(make_class_for_render_function(fn)) - - #----------------------------------------------------------------------------- @@ -190,487 +265,353 @@ class OptionInput(InputTypeBase): Example: The location of the sky + + # TODO: allow ordering to be randomized """ template = "optioninput.html" tags = ['optioninput'] - def _get_render_context(self): - return _optioninput(self.xml, self.value, self.status, self.system.render_template, self.msg) + @staticmethod + def parse_options(options): + """ + Given options string, convert it into an ordered list of (option_id, option_description) tuples, where + id==description for now. TODO: make it possible to specify different id and descriptions. + """ + # parse the set of possible options + lexer = shlex.shlex(options[1:-1]) + lexer.quotes = "'" + # Allow options to be separated by whitespace as well as commas + lexer.whitespace = ", " + + # remove quotes + tokens = [x[1:-1] for x in list(lexer)] + + # make list of (option_id, option_description), with description=id + return [(t, t) for t in tokens] + + @classmethod + def get_attributes(cls): + """ + Convert options to a convenient format. + """ + return [Attribute('options', transform=cls.parse_options), + Attribute('inline', '')] + +registry.register(OptionInput) + +#----------------------------------------------------------------------------- -def optioninput(element, value, status, render_template, msg=''): - context = _optioninput(element, value, status, render_template, msg) - html = render_template("optioninput.html", context) - return etree.XML(html) +# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of +# desired semantics. -def _optioninput(element, value, status, render_template, msg=''): +class ChoiceGroup(InputTypeBase): """ - Select option input type. + Radio button or checkbox inputs: multiple choice or true/false + + TODO: allow order of choices to be randomized, following lon-capa spec. Use + "location" attribute, ie random, top, bottom. Example: - The location of the sky + + + This is foil One. + + + This is foil Two. + + + This is foil Three. + + """ - eid = element.get('id') - options = element.get('options') - if not options: - raise Exception( - "[courseware.capa.inputtypes.optioninput] Missing options specification in " - + etree.tostring(element)) + template = "choicegroup.html" + tags = ['choicegroup', 'radiogroup', 'checkboxgroup'] - # parse the set of possible options - oset = shlex.shlex(options[1:-1]) - oset.quotes = "'" - oset.whitespace = "," - oset = [x[1:-1] for x in list(oset)] + def setup(self): + # suffix is '' or [] to change the way the input is handled in --as a scalar or vector + # value. (VS: would be nice to make this less hackish). + if self.tag == 'choicegroup': + self.suffix = '' + self.html_input_type = "radio" + elif self.tag == 'radiogroup': + self.html_input_type = "radio" + self.suffix = '[]' + elif self.tag == 'checkboxgroup': + self.html_input_type = "checkbox" + self.suffix = '[]' + else: + raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag)) - # make ordered list with (key, value) same - osetdict = [(oset[x], oset[x]) for x in range(len(oset))] - # TODO: allow ordering to be randomized + self.choices = self.extract_choices(self.xml) - context = {'id': eid, - 'value': value, - 'state': status, - 'msg': msg, - 'options': osetdict, - 'inline': element.get('inline',''), - } - return context + def _extra_context(self): + return {'input_type': self.html_input_type, + 'choices': self.choices, + 'name_array_suffix': self.suffix} + + @staticmethod + def extract_choices(element): + ''' + Extracts choices for a few input types, such as ChoiceGroup, RadioGroup and + CheckboxGroup. + + returns list of (choice_name, choice_text) tuples + + TODO: allow order of choices to be randomized, following lon-capa spec. Use + "location" attribute, ie random, top, bottom. + ''' + + choices = [] + + for choice in element: + if choice.tag != 'choice': + raise Exception( + "[capa.inputtypes.extract_choices] Expected a tag; got %s instead" + % choice.tag) + choice_text = ''.join([etree.tostring(x) for x in choice]) + if choice.text is not None: + # TODO: fix order? + choice_text += choice.text + + choices.append((choice.get("name"), choice_text)) + + return choices + + +registry.register(ChoiceGroup) -register_input_class(OptionInput) #----------------------------------------------------------------------------- -# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of -# desired semantics. -# @register_render_function -def choicegroup(element, value, status, render_template, msg=''): - ''' - Radio button inputs: multiple choice or true/false - - TODO: allow order of choices to be randomized, following lon-capa spec. Use - "location" attribute, ie random, top, bottom. - ''' - eid = element.get('id') - if element.get('type') == "MultipleChoice": - element_type = "radio" - elif element.get('type') == "TrueFalse": - element_type = "checkbox" - else: - element_type = "radio" - choices = [] - for choice in element: - if not choice.tag == 'choice': - raise Exception("[courseware.capa.inputtypes.choicegroup] " - "Error: only tags should be immediate children " - "of a , found %s instead" % choice.tag) - ctext = "" - # TODO: what if choice[0] has math tags in it? - ctext += ''.join([etree.tostring(x) for x in choice]) - if choice.text is not None: - # TODO: fix order? - ctext += choice.text - choices.append((choice.get("name"), ctext)) - context = {'id': eid, - 'value': value, - 'state': status, - 'input_type': element_type, - 'choices': choices, - 'name_array_suffix': ''} - html = render_template("choicegroup.html", context) - return etree.XML(html) - -_reg(choicegroup) - -#----------------------------------------------------------------------------- -def extract_choices(element): - ''' - Extracts choices for a few input types, such as radiogroup and - checkboxgroup. - - TODO: allow order of choices to be randomized, following lon-capa spec. Use - "location" attribute, ie random, top, bottom. - ''' - - choices = [] - - for choice in element: - if not choice.tag == 'choice': - raise Exception("[courseware.capa.inputtypes.extract_choices] \ - Expected a tag; got %s instead" - % choice.tag) - choice_text = ''.join([etree.tostring(x) for x in choice]) - - choices.append((choice.get("name"), choice_text)) - - return choices - - -# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of -# desired semantics. -def radiogroup(element, value, status, render_template, msg=''): - ''' - Radio button inputs: (multiple choice) - ''' - - eid = element.get('id') - - choices = extract_choices(element) - - context = {'id': eid, - 'value': value, - 'state': status, - 'input_type': 'radio', - 'choices': choices, - 'name_array_suffix': '[]'} - - html = render_template("choicegroup.html", context) - return etree.XML(html) - - -_reg(radiogroup) - -# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of -# desired semantics. -def checkboxgroup(element, value, status, render_template, msg=''): - ''' - Checkbox inputs: (select one or more choices) - ''' - - eid = element.get('id') - - choices = extract_choices(element) - - context = {'id': eid, - 'value': value, - 'state': status, - 'input_type': 'checkbox', - 'choices': choices, - 'name_array_suffix': '[]'} - - html = render_template("choicegroup.html", context) - return etree.XML(html) - -_reg(checkboxgroup) - -def javascriptinput(element, value, status, render_template, msg='null'): - ''' +class JavascriptInput(InputTypeBase): + """ Hidden field for javascript to communicate via; also loads the required scripts for rendering the problem and passes data to the problem. - ''' - eid = element.get('id') - params = element.get('params') - problem_state = element.get('problem_state') - display_class = element.get('display_class') - display_file = element.get('display_file') - # Need to provide a value that JSON can parse if there is no - # student-supplied value yet. - if value == "": - value = 'null' + TODO (arjun?): document this in detail. Initial notes: + - display_class is a subclass of XProblemClassDisplay (see + xmodule/xmodule/js/src/capa/display.coffee), + - display_file is the js script to be in /static/js/ where display_class is defined. + """ - escapedict = {'"': '"'} - value = saxutils.escape(value, escapedict) - msg = saxutils.escape(msg, escapedict) - context = {'id': eid, - 'params': params, - 'display_file': display_file, - 'display_class': display_class, - 'problem_state': problem_state, - 'value': value, - 'evaluation': msg, - } - html = render_template("javascriptinput.html", context) - return etree.XML(html) + template = "javascriptinput.html" + tags = ['javascriptinput'] -_reg(javascriptinput) + @classmethod + def get_attributes(cls): + """ + Register the attributes. + """ + return [Attribute('params', None), + Attribute('problem_state', None), + Attribute('display_class', None), + Attribute('display_file', None),] -def textline(element, value, status, render_template, msg=""): - ''' - Simple text line input, with optional size specification. - ''' - # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x - if element.get('math') or element.get('dojs'): - return textline_dynamath(element, value, status, render_template, msg) - eid = element.get('id') - if eid is None: - msg = 'textline has no id: it probably appears outside of a known response type' - msg += "\nSee problem XML source line %s" % getattr(element, 'sourceline', '') - raise Exception(msg) + def setup(self): + # Need to provide a value that JSON can parse if there is no + # student-supplied value yet. + if self.value == "": + self.value = 'null' - count = int(eid.split('_')[-2]) - 1 # HACK - size = element.get('size') - # if specified, then textline is hidden and id is stored in div of name given by hidden - hidden = element.get('hidden', '') - - # Escape answers with quotes, so they don't crash the system! - escapedict = {'"': '"'} - value = saxutils.escape(value, escapedict) - - context = {'id': eid, - 'value': value, - 'state': status, - 'count': count, - 'size': size, - 'msg': msg, - 'hidden': hidden, - 'inline': element.get('inline',''), - } - - html = render_template("textinput.html", context) - try: - xhtml = etree.XML(html) - except Exception as err: - # TODO: needs to be self.system.DEBUG - but can't access system - if True: - log.debug('[inputtypes.textline] failed to parse XML for:\n%s' % html) - raise - return xhtml - -_reg(textline) - -#----------------------------------------------------------------------------- - - -def textline_dynamath(element, value, status, render_template, msg=''): - ''' - Text line input with dynamic math display (equation rendered on client in real time - during input). - ''' - # TODO: Make a wrapper for - # TODO: Make an AJAX loop to confirm equation is okay in real-time as user types - ''' - textline is used for simple one-line inputs, like formularesponse and symbolicresponse. - uses a `{::}` - and a hidden textarea with id=input_eid_fromjs for the mathjax rendering and return. - ''' - eid = element.get('id') - count = int(eid.split('_')[-2]) - 1 # HACK - size = element.get('size') - # if specified, then textline is hidden and id is stored in div of name given by hidden - hidden = element.get('hidden', '') - - # Preprocessor to insert between raw input and Mathjax - preprocessor = {'class_name': element.get('preprocessorClassName',''), - 'script_src': element.get('preprocessorSrc','')} - if '' in preprocessor.values(): - preprocessor = None - - # Escape characters in student input for safe XML parsing - escapedict = {'"': '"'} - value = saxutils.escape(value, escapedict) - - context = {'id': eid, - 'value': value, - 'state': status, - 'count': count, - 'size': size, - 'msg': msg, - 'hidden': hidden, - 'preprocessor': preprocessor,} - html = render_template("textinput_dynamath.html", context) - return etree.XML(html) +registry.register(JavascriptInput) #----------------------------------------------------------------------------- -def filesubmission(element, value, status, render_template, msg=''): - ''' - Upload a single file (e.g. for programming assignments) - ''' - eid = element.get('id') - escapedict = {'"': '"'} - allowed_files = json.dumps(element.get('allowed_files', '').split()) - allowed_files = saxutils.escape(allowed_files, escapedict) - required_files = json.dumps(element.get('required_files', '').split()) - required_files = saxutils.escape(required_files, escapedict) - # Check if problem has been queued - queue_len = 0 - # Flag indicating that the problem has been queued, 'msg' is length of queue - if status == 'incomplete': - status = 'queued' - queue_len = msg - msg = 'Submitted to grader.' +class TextLine(InputTypeBase): + """ + A text line input. Can do math preview if "math"="1" is specified. - context = { 'id': eid, - 'state': status, - 'msg': msg, - 'value': value, - 'queue_len': queue_len, - 'allowed_files': allowed_files, - 'required_files': required_files,} - html = render_template("filesubmission.html", context) - return etree.XML(html) + If the hidden attribute is specified, the textline is hidden and the input id is stored in a div with name equal + to the value of the hidden attribute. This is used e.g. for embedding simulations turned into questions. + """ -_reg(filesubmission) + template = "textline.html" + tags = ['textline'] -#----------------------------------------------------------------------------- -## TODO: Make a wrapper for -def textbox(element, value, status, render_template, msg=''): - ''' - The textbox is used for code input. The message is the return HTML string from - evaluating the code, eg error messages, and output from the code tests. - - ''' - eid = element.get('id') - count = int(eid.split('_')[-2]) - 1 # HACK - size = element.get('size') - rows = element.get('rows') or '30' - cols = element.get('cols') or '80' - # if specified, then textline is hidden and id is stored in div of name given by hidden - hidden = element.get('hidden', '') - - # if no student input yet, then use the default input given by the problem - if not value: - value = element.text - - # Check if problem has been queued - queue_len = 0 - # Flag indicating that the problem has been queued, 'msg' is length of queue - if status == 'incomplete': - status = 'queued' - queue_len = msg - msg = 'Submitted to grader.' - - # For CodeMirror - mode = element.get('mode','python') - linenumbers = element.get('linenumbers','true') - tabsize = element.get('tabsize','4') - tabsize = int(tabsize) - - context = {'id': eid, - 'value': value, - 'state': status, - 'count': count, - 'size': size, - 'msg': msg, - 'mode': mode, - 'linenumbers': linenumbers, - 'rows': rows, - 'cols': cols, - 'hidden': hidden, - 'tabsize': tabsize, - 'queue_len': queue_len, - } - html = render_template("textbox.html", context) - try: - xhtml = etree.XML(html) - except Exception as err: - newmsg = 'error %s in rendering message' % (str(err).replace('<', '<')) - newmsg += '
    Original message: %s' % msg.replace('<', '<') - context['msg'] = newmsg - html = render_template("textbox.html", context) - xhtml = etree.XML(html) - return xhtml + @classmethod + def get_attributes(cls): + """ + Register the attributes. + """ + return [ + Attribute('size', None), -_reg(textbox) + Attribute('hidden', False), + Attribute('inline', False), -#----------------------------------------------------------------------------- -def schematic(element, value, status, render_template, msg=''): - eid = element.get('id') - height = element.get('height') - width = element.get('width') - parts = element.get('parts') - analyses = element.get('analyses') - initial_value = element.get('initial_value') - submit_analyses = element.get('submit_analyses') - context = { - 'id': eid, - 'value': value, - 'initial_value': initial_value, - 'state': status, - 'width': width, - 'height': height, - 'parts': parts, - 'analyses': analyses, - 'submit_analyses': submit_analyses, - } - html = render_template("schematicinput.html", context) - return etree.XML(html) + # Attributes below used in setup(), not rendered directly. + Attribute('math', None, render=False), + # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x + Attribute('dojs', None, render=False), + Attribute('preprocessorClassName', None, render=False), + Attribute('preprocessorSrc', None, render=False), + ] -_reg(schematic) -#----------------------------------------------------------------------------- -### TODO: Move out of inputtypes -def math(element, value, status, render_template, msg=''): - ''' - This is not really an input type. It is a convention from Lon-CAPA, used for - displaying a math equation. + def setup(self): + self.do_math = bool(self.loaded_attributes['math'] or + self.loaded_attributes['dojs']) - Examples: + # TODO: do math checking using ajax instead of using js, so + # that we only have one math parser. + self.preprocessor = None + if self.do_math: + # Preprocessor to insert between raw input and Mathjax + self.preprocessor = {'class_name': self.loaded_attributes['preprocessorClassName'], + 'script_src': self.loaded_attributes['preprocessorSrc']} + if None in self.preprocessor.values(): + self.preprocessor = None - $\displaystyle U(r)=4 U_0 - $r_0$ - We convert these to [mathjax]...[/mathjax] and [mathjaxinline]...[/mathjaxinline] + def _extra_context(self): + return {'do_math': self.do_math, + 'preprocessor': self.preprocessor,} - TODO: use shorter tags (but this will require converting problem XML files!) - ''' - mathstr = re.sub('\$(.*)\$', '[mathjaxinline]\\1[/mathjaxinline]', element.text) - mtag = 'mathjax' - if not '\\displaystyle' in mathstr: mtag += 'inline' - else: mathstr = mathstr.replace('\\displaystyle', '') - mathstr = mathstr.replace('mathjaxinline]', '%s]' % mtag) - - #if '\\displaystyle' in mathstr: - # isinline = False - # mathstr = mathstr.replace('\\displaystyle','') - #else: - # isinline = True - # html = render_template("mathstring.html", {'mathstr':mathstr, - # 'isinline':isinline,'tail':element.tail}) - - html = '%s%s' % (mathstr, saxutils.escape(element.tail)) - try: - xhtml = etree.XML(html) - except Exception as err: - if False: # TODO needs to be self.system.DEBUG - but can't access system - msg = '

    Error %s

    ' % str(err).replace('<', '<') - msg += ('

    Failed to construct math expression from

    %s

    ' % - html.replace('<', '<')) - msg += "
    " - log.error(msg) - return etree.XML(msg) - else: - raise - # xhtml.tail = element.tail # don't forget to include the tail! - return xhtml - -_reg(math) +registry.register(TextLine) #----------------------------------------------------------------------------- +class FileSubmission(InputTypeBase): + """ + Upload some files (e.g. for programming assignments) + """ -def solution(element, value, status, render_template, msg=''): - ''' - This is not really an input type. It is just a ... which is given an ID, - that is used for displaying an extended answer (a problem "solution") after "show answers" - is pressed. Note that the solution content is NOT sent with the HTML. It is obtained - by an ajax call. - ''' - eid = element.get('id') - size = element.get('size') - context = {'id': eid, - 'value': value, - 'state': status, - 'size': size, - 'msg': msg, - } - html = render_template("solutionspan.html", context) - return etree.XML(html) + template = "filesubmission.html" + tags = ['filesubmission'] + + # pulled out for testing + submitted_msg = ("Your file(s) have been submitted; as soon as your submission is" + " graded, this message will be replaced with the grader's feedback.") + + @staticmethod + def parse_files(files): + """ + Given a string like 'a.py b.py c.out', split on whitespace and return as a json list. + """ + return json.dumps(files.split()) + + @classmethod + def get_attributes(cls): + """ + Convert the list of allowed files to a convenient format. + """ + return [Attribute('allowed_files', '[]', transform=cls.parse_files), + Attribute('required_files', '[]', transform=cls.parse_files),] + + def setup(self): + """ + Do some magic to handle queueing status (render as "queued" instead of "incomplete"), + pull queue_len from the msg field. (TODO: get rid of the queue_len hack). + """ + # Check if problem has been queued + self.queue_len = 0 + # Flag indicating that the problem has been queued, 'msg' is length of queue + if self.status == 'incomplete': + self.status = 'queued' + self.queue_len = self.msg + self.msg = FileSubmission.submitted_msg + + def _extra_context(self): + return {'queue_len': self.queue_len,} + return context + +registry.register(FileSubmission) -_reg(solution) #----------------------------------------------------------------------------- +class CodeInput(InputTypeBase): + """ + A text area input for code--uses codemirror, does syntax highlighting, special tab handling, + etc. + """ -def imageinput(element, value, status, render_template, msg=''): - ''' + template = "codeinput.html" + tags = ['codeinput', + 'textbox', # Another (older) name--at some point we may want to make it use a + # non-codemirror editor. + ] + + # pulled out for testing + submitted_msg = ("Submitted. As soon as your submission is" + " graded, this message will be replaced with the grader's feedback.") + + @classmethod + def get_attributes(cls): + """ + Convert options to a convenient format. + """ + return [Attribute('rows', '30'), + Attribute('cols', '80'), + Attribute('hidden', ''), + + # For CodeMirror + Attribute('mode', 'python'), + Attribute('linenumbers', 'true'), + # Template expects tabsize to be an int it can do math with + Attribute('tabsize', 4, transform=int), + ] + + def setup(self): + """ + Implement special logic: handle queueing state, and default input. + """ + # if no student input yet, then use the default input given by the problem + if not self.value: + self.value = self.xml.text + + # Check if problem has been queued + self.queue_len = 0 + # Flag indicating that the problem has been queued, 'msg' is length of queue + if self.status == 'incomplete': + self.status = 'queued' + self.queue_len = self.msg + self.msg = self.submitted_msg + + def _extra_context(self): + """Defined queue_len, add it """ + return {'queue_len': self.queue_len,} + +registry.register(CodeInput) + + +#----------------------------------------------------------------------------- +class Schematic(InputTypeBase): + """ + """ + + template = "schematicinput.html" + tags = ['schematic'] + + @classmethod + def get_attributes(cls): + """ + Convert options to a convenient format. + """ + return [ + Attribute('height', None), + Attribute('width', None), + Attribute('parts', None), + Attribute('analyses', None), + Attribute('initial_value', None), + Attribute('submit_analyses', None),] + + return context + +registry.register(Schematic) + +#----------------------------------------------------------------------------- + +class ImageInput(InputTypeBase): + """ Clickable image as an input field. Element should specify the image source, height, and width, e.g. @@ -678,79 +619,91 @@ def imageinput(element, value, status, render_template, msg=''): TODO: showanswer for imageimput does not work yet - need javascript to put rectangle over acceptable area of image. - ''' - eid = element.get('id') - src = element.get('src') - height = element.get('height') - width = element.get('width') + """ - # if value is of the form [x,y] then parse it and send along coordinates of previous answer - m = re.match('\[([0-9]+),([0-9]+)]', value.strip().replace(' ', '')) - if m: - (gx, gy) = [int(x) - 15 for x in m.groups()] - else: - (gx, gy) = (0, 0) + template = "imageinput.html" + tags = ['imageinput'] - context = { - 'id': eid, - 'value': value, - 'height': height, - 'width': width, - 'src': src, - 'gx': gx, - 'gy': gy, - 'state': status, # to change - 'msg': msg, # to change - } - html = render_template("imageinput.html", context) - return etree.XML(html) + @classmethod + def get_attributes(cls): + """ + Note: src, height, and width are all required. + """ + return [Attribute('src'), + Attribute('height'), + Attribute('width'),] -_reg(imageinput) + + def setup(self): + """ + if value is of the form [x,y] then parse it and send along coordinates of previous answer + """ + m = re.match('\[([0-9]+),([0-9]+)]', self.value.strip().replace(' ', '')) + if m: + # Note: we subtract 15 to compensate for the size of the dot on the screen. + # (is a 30x30 image--lms/static/green-pointer.png). + (self.gx, self.gy) = [int(x) - 15 for x in m.groups()] + else: + (self.gx, self.gy) = (0, 0) + + + def _extra_context(self): + + return {'gx': self.gx, + 'gy': self.gy} + +registry.register(ImageInput) #----------------------------------------------------------------------------- -def crystallography(element, value, status, render_template, msg=''): - eid = element.get('id') - if eid is None: - msg = 'cryst has no id: it probably appears outside of a known response type' - msg += "\nSee problem XML source line %s" % getattr(element, 'sourceline', '') - raise Exception(msg) - height = element.get('height') - width = element.get('width') - display_file = element.get('display_file') - count = int(eid.split('_')[-2]) - 1 # HACK - size = element.get('size') - # if specified, then textline is hidden and id is stored in div of name given by hidden - hidden = element.get('hidden', '') - # Escape answers with quotes, so they don't crash the system! - escapedict = {'"': '"'} - value = saxutils.escape(value, escapedict) +class Crystallography(InputTypeBase): + """ + An input for crystallography -- user selects 3 points on the axes, and we get a plane. - context = {'id': eid, - 'value': value, - 'state': status, - 'count': count, - 'size': size, - 'msg': msg, - 'hidden': hidden, - 'inline': element.get('inline', ''), - 'width': width, - 'height': height, - 'display_file': display_file, - } + TODO: what's the actual value format? + """ - html = render_template("crystallography.html", context) - try: - xhtml = etree.XML(html) - except Exception as err: - # TODO: needs to be self.system.DEBUG - but can't access system - if True: - log.debug('[inputtypes.textline] failed to parse XML for:\n%s' % html) - raise - return xhtml + template = "crystallography.html" + tags = ['crystallography'] -_reg(crystallography) + @classmethod + def get_attributes(cls): + """ + Note: height, width are required. + """ + return [Attribute('size', None), + Attribute('height'), + Attribute('width'), + # can probably be removed (textline should prob be always-hidden) + Attribute('hidden', ''), + ] + +registry.register(Crystallography) + +# ------------------------------------------------------------------------- + +class VseprInput(InputTypeBase): + """ + Input for molecular geometry--show possible structures, let student + pick structure and label positions with atoms or electron pairs. + """ + + template = 'vsepr_input.html' + tags = ['vsepr_input'] + + @classmethod + def get_attributes(cls): + """ + Note: height, width are required. + """ + return [Attribute('height'), + Attribute('width'), + Attribute('molecules'), + Attribute('geometries'), + ] + +registry.register(VseprInput) #-------------------------------------------------------------------------------- @@ -769,15 +722,17 @@ class ChemicalEquationInput(InputTypeBase): template = "chemicalequationinput.html" tags = ['chemicalequationinput'] - def _get_render_context(self): - size = self.xml.get('size', '20') - context = { - 'id': self.id, - 'value': self.value, - 'status': self.status, - 'size': size, - 'previewer': '/static/js/capa/chemical_equation_preview.js', - } - return context + @classmethod + def get_attributes(cls): + """ + Can set size of text field. + """ + return [Attribute('size', '20'),] -register_input_class(ChemicalEquationInput) + def _extra_context(self): + """ + TODO (vshnayder): Get rid of this once we have a standard way of requiring js to be loaded. + """ + return {'previewer': '/static/js/capa/chemical_equation_preview.js',} + +registry.register(ChemicalEquationInput) diff --git a/common/lib/capa/capa/registry.py b/common/lib/capa/capa/registry.py new file mode 100644 index 0000000000..94a2853dec --- /dev/null +++ b/common/lib/capa/capa/registry.py @@ -0,0 +1,49 @@ +class TagRegistry(object): + """ + A registry mapping tags to handlers. + + (A dictionary with some extra error checking.) + """ + def __init__(self): + self._mapping = {} + + def register(self, cls): + """ + Register cls as a supported tag type. It is expected to define cls.tags as a list of tags + that it implements. + + If an already-registered type has registered one of those tags, will raise ValueError. + + If there are no tags in cls.tags, will also raise ValueError. + """ + + # Do all checks and complain before changing any state. + if len(cls.tags) == 0: + raise ValueError("No tags specified for class {0}".format(cls.__name__)) + + for t in cls.tags: + if t in self._mapping: + other_cls = self._mapping[t] + if cls == other_cls: + # registering the same class multiple times seems silly, but ok + continue + raise ValueError("Tag {0} already registered by class {1}." + " Can't register for class {2}" + .format(t, other_cls.__name__, cls.__name__)) + + # Ok, should be good to change state now. + for t in cls.tags: + self._mapping[t] = cls + + def registered_tags(self): + """ + Get a list of all the tags that have been registered. + """ + return self._mapping.keys() + + def get_class_for_tag(self, tag): + """ + For any tag in registered_tags(), returns the corresponding class. Otherwise, will raise + KeyError. + """ + return self._mapping[tag] diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 46b683cf3c..70373e0a47 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -81,7 +81,7 @@ class LoncapaResponse(object): by __init__ - check_hint_condition : check to see if the student's answers satisfy a particular - condition for a hint to be displayed + condition for a hint to be displayed - render_html : render this Response as HTML (must return XHTML-compliant string) - __unicode__ : unicode representation of this Response @@ -148,6 +148,7 @@ class LoncapaResponse(object): # for convenience self.answer_id = self.answer_ids[0] + # map input_id -> maxpoints self.maxpoints = dict() for inputfield in self.inputfields: # By default, each answerfield is worth 1 point @@ -284,17 +285,14 @@ class LoncapaResponse(object): (correctness, npoints, msg) for each answer_id. Arguments: - - student_answers : dict of (answer_id,answer) where answer = student input (string) - - - old_cmap : previous CorrectMap (may be empty); useful for analyzing or - recording history of responses + - student_answers : dict of (answer_id, answer) where answer = student input (string) ''' pass @abc.abstractmethod def get_answers(self): ''' - Return a dict of (answer_id,answer_text) for each answer for this question. + Return a dict of (answer_id, answer_text) for each answer for this question. ''' pass @@ -871,7 +869,8 @@ def sympy_check2(): """}] response_tag = 'customresponse' - allowed_inputfields = ['textline', 'textbox', 'crystallography', 'chemicalequationinput'] + + allowed_inputfields = ['textline', 'textbox', 'crystallography', 'chemicalequationinput', 'vsepr_input'] def setup_response(self): xml = self.xml @@ -1720,7 +1719,7 @@ class ImageResponse(LoncapaResponse): """ Handle student response for image input: the input is a click on an image, which produces an [x,y] coordinate pair. The click is correct if it falls - within a region specified. This region is nominally a rectangle. + within a region specified. This region is a union of rectangles. Lon-CAPA requires that each has a inside it. That doesn't make sense to me (Ike). Instead, let's have it such that @@ -1730,6 +1729,7 @@ class ImageResponse(LoncapaResponse): snippets = [{'snippet': ''' + '''}] response_tag = 'imageresponse' @@ -1746,20 +1746,10 @@ class ImageResponse(LoncapaResponse): for aid in self.answer_ids: # loop through IDs of fields in our stanza given = student_answers[aid] # this should be a string of the form '[x,y]' + correct_map.set(aid, 'incorrect') if not given: # No answer to parse. Mark as incorrect and move on - correct_map.set(aid, 'incorrect') continue - # parse expected answer - # TODO: Compile regexp on file load - m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]', - expectedset[aid].strip().replace(' ', '')) - if not m: - msg = 'Error in problem specification! cannot parse rectangle in %s' % ( - etree.tostring(self.ielements[aid], pretty_print=True)) - raise Exception('[capamodule.capa.responsetypes.imageinput] ' + msg) - (llx, lly, urx, ury) = [int(x) for x in m.groups()] - # parse given answer m = re.match('\[([0-9]+),([0-9]+)]', given.strip().replace(' ', '')) if not m: @@ -1767,11 +1757,24 @@ class ImageResponse(LoncapaResponse): 'error grading %s (input=%s)' % (aid, given)) (gx, gy) = [int(x) for x in m.groups()] - # answer is correct if (x,y) is within the specified rectangle - if (llx <= gx <= urx) and (lly <= gy <= ury): - correct_map.set(aid, 'correct') - else: - correct_map.set(aid, 'incorrect') + # Check whether given point lies in any of the solution rectangles + solution_rectangles = expectedset[aid].split(';') + for solution_rectangle in solution_rectangles: + # parse expected answer + # TODO: Compile regexp on file load + m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]', + solution_rectangle.strip().replace(' ', '')) + if not m: + msg = 'Error in problem specification! cannot parse rectangle in %s' % ( + etree.tostring(self.ielements[aid], pretty_print=True)) + raise Exception('[capamodule.capa.responsetypes.imageinput] ' + msg) + (llx, lly, urx, ury) = [int(x) for x in m.groups()] + + # answer is correct if (x,y) is within the specified rectangle + if (llx <= gx <= urx) and (lly <= gy <= ury): + correct_map.set(aid, 'correct') + break + return correct_map def get_answers(self): diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index ce1b6d9476..457d9e7817 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -1,12 +1,12 @@
    - % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif
    diff --git a/common/lib/capa/capa/templates/textbox.html b/common/lib/capa/capa/templates/codeinput.html similarity index 90% rename from common/lib/capa/capa/templates/textbox.html rename to common/lib/capa/capa/templates/codeinput.html index 6e44712d9f..5c2ff2aca5 100644 --- a/common/lib/capa/capa/templates/textbox.html +++ b/common/lib/capa/capa/templates/codeinput.html @@ -6,13 +6,13 @@ >${value|h}
    - % if state == 'unsubmitted': + % if status == 'unsubmitted': Unanswered - % elif state == 'correct': + % elif status == 'correct': Correct - % elif state == 'incorrect': + % elif status == 'incorrect': Incorrect - % elif state == 'queued': + % elif status == 'queued': Queued ${queue_len} % endif @@ -21,7 +21,7 @@
    % endif -

    ${state}

    +

    ${status}

    diff --git a/common/lib/capa/capa/templates/crystallography.html b/common/lib/capa/capa/templates/crystallography.html index 1fc638b356..2370f59dd2 100644 --- a/common/lib/capa/capa/templates/crystallography.html +++ b/common/lib/capa/capa/templates/crystallography.html @@ -1,25 +1,25 @@ -<% doinline = "inline" if inline else "" %> - -
    +
    -
    +
    +
    +
    - % if state == 'unsubmitted': -
    - % elif state == 'correct': -
    - % elif state == 'incorrect': -
    - % elif state == 'incomplete': -
    + % if status == 'unsubmitted': +
    + % elif status == 'correct': +
    + % elif status == 'incorrect': +
    + % elif status == 'incomplete': +
    % endif % if hidden:
    % endif -

    - % if state == 'unsubmitted': + % if status == 'unsubmitted': unanswered - % elif state == 'correct': + % elif status == 'correct': correct - % elif state == 'incorrect': + % elif status == 'incorrect': incorrect - % elif state == 'incomplete': + % elif status == 'incomplete': incomplete % endif

    @@ -45,7 +45,7 @@ % if msg: ${msg|n} % endif -% if state in ['unsubmitted', 'correct', 'incorrect', 'incomplete'] or hidden: +% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
    % endif
    diff --git a/common/lib/capa/capa/templates/filesubmission.html b/common/lib/capa/capa/templates/filesubmission.html index 630a3222dc..930469dc0d 100644 --- a/common/lib/capa/capa/templates/filesubmission.html +++ b/common/lib/capa/capa/templates/filesubmission.html @@ -1,18 +1,18 @@
    - % if state == 'unsubmitted': + % if status == 'unsubmitted': Unanswered - % elif state == 'correct': + % elif status == 'correct': Correct - % elif state == 'incorrect': + % elif status == 'incorrect': Incorrect - % elif state == 'queued': + % elif status == 'queued': Queued ${queue_len} % endif -

    ${state}

    +

    ${status}

    - +
    ${msg|n}
    diff --git a/common/lib/capa/capa/templates/imageinput.html b/common/lib/capa/capa/templates/imageinput.html index ceda98ee8f..97279c1b8c 100644 --- a/common/lib/capa/capa/templates/imageinput.html +++ b/common/lib/capa/capa/templates/imageinput.html @@ -4,13 +4,13 @@
    - % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif diff --git a/common/lib/capa/capa/templates/javascriptinput.html b/common/lib/capa/capa/templates/javascriptinput.html index 8b4c8f7115..b4d007e4d8 100644 --- a/common/lib/capa/capa/templates/javascriptinput.html +++ b/common/lib/capa/capa/templates/javascriptinput.html @@ -2,7 +2,7 @@
    + data-submission="${value|h}" data-evaluation="${msg|h}">
    diff --git a/common/lib/capa/capa/templates/jstextline.html b/common/lib/capa/capa/templates/jstextline.html index 045f329ad4..7e5fd94a4b 100644 --- a/common/lib/capa/capa/templates/jstextline.html +++ b/common/lib/capa/capa/templates/jstextline.html @@ -18,13 +18,13 @@ % endif - % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif % if msg: diff --git a/common/lib/capa/capa/templates/optioninput.html b/common/lib/capa/capa/templates/optioninput.html index 06c7ed1257..ac62c0c9cb 100644 --- a/common/lib/capa/capa/templates/optioninput.html +++ b/common/lib/capa/capa/templates/optioninput.html @@ -12,13 +12,13 @@ - % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif
    diff --git a/common/lib/capa/capa/templates/schematicinput.html b/common/lib/capa/capa/templates/schematicinput.html index f79dc66d24..ff8cc64073 100644 --- a/common/lib/capa/capa/templates/schematicinput.html +++ b/common/lib/capa/capa/templates/schematicinput.html @@ -12,13 +12,13 @@ - % if state == 'unsubmitted': + % if status == 'unsubmitted': - % elif state == 'correct': + % elif status == 'correct': - % elif state == 'incorrect': + % elif status == 'incorrect': - % elif state == 'incomplete': + % elif status == 'incomplete': % endif diff --git a/common/lib/capa/capa/templates/textinput.html b/common/lib/capa/capa/templates/textinput.html deleted file mode 100644 index 9b66654117..0000000000 --- a/common/lib/capa/capa/templates/textinput.html +++ /dev/null @@ -1,46 +0,0 @@ -<% doinline = "inline" if inline else "" %> - -
    - % if state == 'unsubmitted': -
    - % elif state == 'correct': -
    - % elif state == 'incorrect': -
    - % elif state == 'incomplete': -
    - % endif - % if hidden: -
    - % endif - - - -

    - % if state == 'unsubmitted': - unanswered - % elif state == 'correct': - correct - % elif state == 'incorrect': - incorrect - % elif state == 'incomplete': - incomplete - % endif -

    - -

    - - % if msg: - ${msg|n} - % endif -% if state in ['unsubmitted', 'correct', 'incorrect', 'incomplete'] or hidden: -
    -% endif -
    diff --git a/common/lib/capa/capa/templates/textinput_dynamath.html b/common/lib/capa/capa/templates/textinput_dynamath.html deleted file mode 100644 index d1de22ef27..0000000000 --- a/common/lib/capa/capa/templates/textinput_dynamath.html +++ /dev/null @@ -1,50 +0,0 @@ -### -### version of textline.html which does dynamic math -### -
    - - % if preprocessor is not None: -
    -
    - % endif - - % if state == 'unsubmitted': -
    - % elif state == 'correct': -
    - % elif state == 'incorrect': -
    - % elif state == 'incomplete': -
    - % endif - % if hidden: -
    - % endif - - -

    - % if state == 'unsubmitted': - unanswered - % elif state == 'correct': - correct - % elif state == 'incorrect': - incorrect - % elif state == 'incomplete': - incomplete - % endif -

    - -

    - -
    `{::}`
    - -
    - - % if msg: - ${msg|n} - % endif -
    diff --git a/common/lib/capa/capa/templates/textline.html b/common/lib/capa/capa/templates/textline.html new file mode 100644 index 0000000000..fbb5467b67 --- /dev/null +++ b/common/lib/capa/capa/templates/textline.html @@ -0,0 +1,64 @@ +<% doinline = "inline" if inline else "" %> + +
    + + % if preprocessor is not None: +
    +
    + % endif + + % if status == 'unsubmitted': +
    + % elif status == 'correct': +
    + % elif status == 'incorrect': +
    + % elif status == 'incomplete': +
    + % endif + % if hidden: +
    + % endif + + + +

    + % if status == 'unsubmitted': + unanswered + % elif status == 'correct': + correct + % elif status == 'incorrect': + incorrect + % elif status == 'incomplete': + incomplete + % endif +

    + +

    + + % if do_math: +
    `{::}`
    + + + % endif + +% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
    +% endif + + % if msg: + ${msg|n} + % endif + +
    diff --git a/common/lib/capa/capa/templates/vsepr_input.html b/common/lib/capa/capa/templates/vsepr_input.html new file mode 100644 index 0000000000..1bac2fd6e2 --- /dev/null +++ b/common/lib/capa/capa/templates/vsepr_input.html @@ -0,0 +1,48 @@ +
    +
    +
    + + +
    +
    + +
    + +
    + + % if status == 'unsubmitted': +
    + % elif status == 'correct': +
    + % elif status == 'incorrect': +
    + % elif status == 'incomplete': +
    + % endif + + + +

    + % if status == 'unsubmitted': + unanswered + % elif status == 'correct': + correct + % elif status == 'incorrect': + incorrect + % elif status == 'incomplete': + incomplete + % endif +

    + +

    + + % if msg: + ${msg|n} + % endif +% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
    +% endif +
    diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index c72d2a1538..b06975f6ce 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -4,13 +4,23 @@ import os from mock import Mock +import xml.sax.saxutils as saxutils + TEST_DIR = os.path.dirname(os.path.realpath(__file__)) +def tst_render_template(template, context): + """ + A test version of render to template. Renders to the repr of the context, completely ignoring + the template name. To make the output valid xml, quotes the content, and wraps it in a
    + """ + return '
    {0}
    '.format(saxutils.escape(repr(context))) + + test_system = Mock( ajax_url='courses/course_id/modx/a_location', track_function=Mock(), get_module=Mock(), - render_template=Mock(), + render_template=tst_render_template, replace_urls=Mock(), user=Mock(), filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), diff --git a/common/lib/capa/capa/tests/test_customrender.py b/common/lib/capa/capa/tests/test_customrender.py new file mode 100644 index 0000000000..7208ab2941 --- /dev/null +++ b/common/lib/capa/capa/tests/test_customrender.py @@ -0,0 +1,76 @@ +from lxml import etree +import unittest +import xml.sax.saxutils as saxutils + +from . import test_system +from capa import customrender + +# just a handy shortcut +lookup_tag = customrender.registry.get_class_for_tag + +def extract_context(xml): + """ + Given an xml element corresponding to the output of test_system.render_template, get back the + original context + """ + return eval(xml.text) + +def quote_attr(s): + return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes + +class HelperTest(unittest.TestCase): + ''' + Make sure that our helper function works! + ''' + def check(self, d): + xml = etree.XML(test_system.render_template('blah', d)) + self.assertEqual(d, extract_context(xml)) + + def test_extract_context(self): + self.check({}) + self.check({1, 2}) + self.check({'id', 'an id'}) + self.check({'with"quote', 'also"quote'}) + + +class SolutionRenderTest(unittest.TestCase): + ''' + Make sure solutions render properly. + ''' + + def test_rendering(self): + solution = 'To compute unicorns, count them.' + xml_str = """{s}""".format(s=solution) + element = etree.fromstring(xml_str) + + renderer = lookup_tag('solution')(test_system, element) + + self.assertEqual(renderer.id, 'solution_12') + + # our test_system "renders" templates to a div with the repr of the context + xml = renderer.get_html() + context = extract_context(xml) + self.assertEqual(context, {'id' : 'solution_12'}) + + +class MathRenderTest(unittest.TestCase): + ''' + Make sure math renders properly. + ''' + + def check_parse(self, latex_in, mathjax_out): + xml_str = """{tex}""".format(tex=latex_in) + element = etree.fromstring(xml_str) + + renderer = lookup_tag('math')(test_system, element) + + self.assertEqual(renderer.mathstr, mathjax_out) + + def test_parsing(self): + self.check_parse('$abc$', '[mathjaxinline]abc[/mathjaxinline]') + self.check_parse('$abc', '$abc') + self.check_parse(r'$\displaystyle 2+2$', '[mathjax] 2+2[/mathjax]') + + + # NOTE: not testing get_html yet because I don't understand why it's doing what it's doing. + diff --git a/common/lib/capa/capa/tests/test_files/imageresponse.xml b/common/lib/capa/capa/tests/test_files/imageresponse.xml index 72bf06401a..34dba37e3b 100644 --- a/common/lib/capa/capa/tests/test_files/imageresponse.xml +++ b/common/lib/capa/capa/tests/test_files/imageresponse.xml @@ -8,8 +8,14 @@ Hello

    Click on the image where the top skier will stop momentarily if the top skier starts from rest. Click on the image where the lower skier will stop momentarily if the lower skier starts from rest. + +Click on either of the two positions as discussed previously. + +Click on either of the two positions as discussed previously. + +Click on either of the two positions as discussed previously.

    Use conservation of energy.

    - \ No newline at end of file + diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 9ef642d468..826d304717 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -1,50 +1,39 @@ """ -Tests of input types (and actually responsetypes too) +Tests of input types. + +TODO: +- refactor: so much repetive code (have factory methods that build xml elements directly, etc) + +- test error cases + +- check rendering -- e.g. msg should appear in the rendered output. If possible, test that + templates are escaping things properly. + + +- test unicode in values, parameters, etc. +- test various html escapes +- test funny xml chars -- should never get xml parse error if things are escaped properly. + """ -from datetime import datetime -import json -from mock import Mock -from nose.plugins.skip import SkipTest -import os +from lxml import etree import unittest +import xml.sax.saxutils as saxutils from . import test_system from capa import inputtypes -from lxml import etree - -def tst_render_template(template, context): - """ - A test version of render to template. Renders to the repr of the context, completely ignoring the template name. - """ - return repr(context) +# just a handy shortcut +lookup_tag = inputtypes.registry.get_class_for_tag -system = Mock(render_template=tst_render_template) +def quote_attr(s): + return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes class OptionInputTest(unittest.TestCase): ''' Make sure option inputs work ''' - def test_rendering_new(self): - xml = """""" - element = etree.fromstring(xml) - - value = 'Down' - status = 'answered' - context = inputtypes._optioninput(element, value, status, test_system.render_template) - print 'context: ', context - - expected = {'value': 'Down', - 'options': [('Up', 'Up'), ('Down', 'Down')], - 'state': 'answered', - 'msg': '', - 'inline': '', - 'id': 'sky_input'} - - self.assertEqual(context, expected) - def test_rendering(self): xml_str = """""" @@ -53,16 +42,466 @@ class OptionInputTest(unittest.TestCase): state = {'value': 'Down', 'id': 'sky_input', 'status': 'answered'} - option_input = inputtypes.OptionInput(system, element, state) + option_input = lookup_tag('optioninput')(test_system, element, state) context = option_input._get_render_context() expected = {'value': 'Down', 'options': [('Up', 'Up'), ('Down', 'Down')], - 'state': 'answered', + 'status': 'answered', 'msg': '', 'inline': '', 'id': 'sky_input'} self.assertEqual(context, expected) + def test_option_parsing(self): + f = inputtypes.OptionInput.parse_options + def check(input, options): + """Take list of options, confirm that output is in the silly doubled format""" + expected = [(o, o) for o in options] + self.assertEqual(f(input), expected) + + check("('a','b')", ['a', 'b']) + check("('a', 'b')", ['a', 'b']) + check("('a b','b')", ['a b', 'b']) + check("('My \"quoted\"place','b')", ['My \"quoted\"place', 'b']) + + +class ChoiceGroupTest(unittest.TestCase): + ''' + Test choice groups, radio groups, and checkbox groups + ''' + + def check_group(self, tag, expected_input_type, expected_suffix): + xml_str = """ + <{tag}> + This is foil One. + This is foil Two. + This is foil Three. + + """.format(tag=tag) + + element = etree.fromstring(xml_str) + + state = {'value': 'foil3', + 'id': 'sky_input', + 'status': 'answered'} + + the_input = lookup_tag(tag)(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'sky_input', + 'value': 'foil3', + 'status': 'answered', + 'msg': '', + 'input_type': expected_input_type, + 'choices': [('foil1', 'This is foil One.'), + ('foil2', 'This is foil Two.'), + ('foil3', 'This is foil Three.'),], + 'name_array_suffix': expected_suffix, # what is this for?? + } + + self.assertEqual(context, expected) + + def test_choicegroup(self): + self.check_group('choicegroup', 'radio', '') + + def test_radiogroup(self): + self.check_group('radiogroup', 'radio', '[]') + + def test_checkboxgroup(self): + self.check_group('checkboxgroup', 'checkbox', '[]') + + + +class JavascriptInputTest(unittest.TestCase): + ''' + The javascript input is a pretty straightforward pass-thru, but test it anyway + ''' + + def test_rendering(self): + params = "(1,2,3)" + + problem_state = "abc12',12&hi" + display_class = "a_class" + display_file = "my_files/hi.js" + + xml_str = """""".format( + params=params, + ps=quote_attr(problem_state), + dc=display_class, df=display_file) + + element = etree.fromstring(xml_str) + + state = {'value': '3',} + the_input = lookup_tag('javascriptinput')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'status': 'unanswered', + 'msg': '', + 'value': '3', + 'params': params, + 'display_file': display_file, + 'display_class': display_class, + 'problem_state': problem_state,} + + self.assertEqual(context, expected) + + +class TextLineTest(unittest.TestCase): + ''' + Check that textline inputs work, with and without math. + ''' + + def test_rendering(self): + size = "42" + xml_str = """""".format(size=size) + + element = etree.fromstring(xml_str) + + state = {'value': 'BumbleBee',} + the_input = lookup_tag('textline')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'BumbleBee', + 'status': 'unanswered', + 'size': size, + 'msg': '', + 'hidden': False, + 'inline': False, + 'do_math': False, + 'preprocessor': None} + self.assertEqual(context, expected) + + + def test_math_rendering(self): + size = "42" + preprocessorClass = "preParty" + script = "foo/party.js" + + xml_str = """""".format(size=size, pp=preprocessorClass, sc=script) + + element = etree.fromstring(xml_str) + + state = {'value': 'BumbleBee',} + the_input = lookup_tag('textline')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'BumbleBee', + 'status': 'unanswered', + 'size': size, + 'msg': '', + 'hidden': False, + 'inline': False, + 'do_math': True, + 'preprocessor': {'class_name': preprocessorClass, + 'script_src': script}} + self.assertEqual(context, expected) + + +class FileSubmissionTest(unittest.TestCase): + ''' + Check that file submission inputs work + ''' + + def test_rendering(self): + allowed_files = "runme.py nooooo.rb ohai.java" + required_files = "cookies.py" + + xml_str = """""".format(af=allowed_files, + rf=required_files,) + + + element = etree.fromstring(xml_str) + + state = {'value': 'BumbleBee.py', + 'status': 'incomplete', + 'feedback' : {'message': '3'}, } + input_class = lookup_tag('filesubmission') + the_input = input_class(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'status': 'queued', + 'msg': input_class.submitted_msg, + 'value': 'BumbleBee.py', + 'queue_len': '3', + 'allowed_files': '["runme.py", "nooooo.rb", "ohai.java"]', + 'required_files': '["cookies.py"]'} + + self.assertEqual(context, expected) + + +class CodeInputTest(unittest.TestCase): + ''' + Check that codeinput inputs work + ''' + + def test_rendering(self): + mode = "parrot" + linenumbers = 'false' + rows = '37' + cols = '11' + tabsize = '7' + + xml_str = """""".format(m=mode, c=cols, r=rows, ln=linenumbers, ts=tabsize) + + element = etree.fromstring(xml_str) + + escapedict = {'"': '"'} + esc = lambda s: saxutils.escape(s, escapedict) + + state = {'value': 'print "good evening"', + 'status': 'incomplete', + 'feedback' : {'message': '3'}, } + + input_class = lookup_tag('codeinput') + the_input = input_class(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'print "good evening"', + 'status': 'queued', + 'msg': input_class.submitted_msg, + 'mode': mode, + 'linenumbers': linenumbers, + 'rows': rows, + 'cols': cols, + 'hidden': '', + 'tabsize': int(tabsize), + 'queue_len': '3', + } + + self.assertEqual(context, expected) + + +class SchematicTest(unittest.TestCase): + ''' + Check that schematic inputs work + ''' + + def test_rendering(self): + height = '12' + width = '33' + parts = 'resistors, capacitors, and flowers' + analyses = 'fast, slow, and pink' + initial_value = 'two large batteries' + submit_analyses = 'maybe' + + + xml_str = """""".format(h=height, w=width, p=parts, a=analyses, + iv=initial_value, sa=submit_analyses) + + element = etree.fromstring(xml_str) + + value = 'three resistors and an oscilating pendulum' + state = {'value': value, + 'status': 'unsubmitted'} + + the_input = lookup_tag('schematic')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': value, + 'status': 'unsubmitted', + 'msg': '', + 'initial_value': initial_value, + 'width': width, + 'height': height, + 'parts': parts, + 'analyses': analyses, + 'submit_analyses': submit_analyses, + } + + self.assertEqual(context, expected) + + +class ImageInputTest(unittest.TestCase): + ''' + Check that image inputs work + ''' + + def check(self, value, egx, egy): + height = '78' + width = '427' + src = 'http://www.edx.org/cowclicker.jpg' + + xml_str = """""".format(s=src, h=height, w=width) + + element = etree.fromstring(xml_str) + + state = {'value': value, + 'status': 'unsubmitted'} + + the_input = lookup_tag('imageinput')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': value, + 'status': 'unsubmitted', + 'width': width, + 'height': height, + 'src': src, + 'gx': egx, + 'gy': egy, + 'msg': ''} + + self.assertEqual(context, expected) + + def test_with_value(self): + # Check that compensating for the dot size works properly. + self.check('[50,40]', 35, 25) + + def test_without_value(self): + self.check('', 0, 0) + + def test_corrupt_values(self): + self.check('[12', 0, 0) + self.check('[12, a]', 0, 0) + self.check('[12 10]', 0, 0) + self.check('[12]', 0, 0) + self.check('[12 13 14]', 0, 0) + + + +class CrystallographyTest(unittest.TestCase): + ''' + Check that crystallography inputs work + ''' + + def test_rendering(self): + height = '12' + width = '33' + size = '10' + + xml_str = """""".format(h=height, w=width, s=size) + + element = etree.fromstring(xml_str) + + value = 'abc' + state = {'value': value, + 'status': 'unsubmitted'} + + the_input = lookup_tag('crystallography')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': value, + 'status': 'unsubmitted', + 'size': size, + 'msg': '', + 'hidden': '', + 'width': width, + 'height': height, + } + + self.assertEqual(context, expected) + + +class VseprTest(unittest.TestCase): + ''' + Check that vsepr inputs work + ''' + + def test_rendering(self): + height = '12' + width = '33' + molecules = "H2O, C2O" + geometries = "AX12,TK421" + + xml_str = """""".format(h=height, w=width, m=molecules, g=geometries) + + element = etree.fromstring(xml_str) + + value = 'abc' + state = {'value': value, + 'status': 'unsubmitted'} + + the_input = lookup_tag('vsepr_input')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': value, + 'status': 'unsubmitted', + 'msg': '', + 'width': width, + 'height': height, + 'molecules': molecules, + 'geometries': geometries, + } + + self.assertEqual(context, expected) + + + +class ChemicalEquationTest(unittest.TestCase): + ''' + Check that chemical equation inputs work. + ''' + + def test_rendering(self): + size = "42" + xml_str = """""".format(size=size) + + element = etree.fromstring(xml_str) + + state = {'value': 'H2OYeah',} + the_input = lookup_tag('chemicalequationinput')(test_system, element, state) + + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'H2OYeah', + 'status': 'unanswered', + 'msg': '', + 'size': size, + 'previewer': '/static/js/capa/chemical_equation_preview.js', + } + self.assertEqual(context, expected) + diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index f2fa873080..bcac555b5e 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -53,12 +53,22 @@ class ImageResponseTest(unittest.TestCase): imageresponse_file = os.path.dirname(__file__) + "/test_files/imageresponse.xml" test_lcp = lcp.LoncapaProblem(open(imageresponse_file).read(), '1', system=test_system) correct_answers = {'1_2_1': '(490,11)-(556,98)', - '1_2_2': '(242,202)-(296,276)'} + '1_2_2': '(242,202)-(296,276)', + '1_2_3': '(490,11)-(556,98);(242,202)-(296,276)', + '1_2_4': '(490,11)-(556,98);(242,202)-(296,276)', + '1_2_5': '(490,11)-(556,98);(242,202)-(296,276)', + } test_answers = {'1_2_1': '[500,20]', '1_2_2': '[250,300]', + '1_2_3': '[500,20]', + '1_2_4': '[250,250]', + '1_2_5': '[10,10]', } self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct') self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_3'), 'correct') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_4'), 'correct') + self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_5'), 'incorrect') class SymbolicResponseTest(unittest.TestCase): diff --git a/common/lib/xmodule/.coveragerc b/common/lib/xmodule/.coveragerc new file mode 100644 index 0000000000..310c8e778b --- /dev/null +++ b/common/lib/xmodule/.coveragerc @@ -0,0 +1,13 @@ +# .coveragerc for common/lib/xmodule +[run] +data_file = reports/common/lib/xmodule/.coverage +source = common/lib/xmodule + +[report] +ignore_errors = True + +[html] +directory = reports/common/lib/xmodule/cover + +[xml] +output = reports/common/lib/xmodule/coverage.xml diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index c123756655..24cddd2047 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -26,8 +26,9 @@ setup( "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", "error = xmodule.error_module:ErrorDescriptor", "problem = xmodule.capa_module:CapaDescriptor", - "problemset = xmodule.vertical_module:VerticalDescriptor", + "problemset = xmodule.seq_module:SequenceDescriptor", "section = xmodule.backcompat_module:SemanticSectionDescriptor", + "selfassessment = xmodule.self_assessment_module:SelfAssessmentDescriptor", "sequential = xmodule.seq_module:SequenceDescriptor", "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", "vertical = xmodule.vertical_module:VerticalDescriptor", @@ -35,6 +36,10 @@ setup( "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", "videosequence = xmodule.seq_module:SequenceDescriptor", "discussion = xmodule.discussion_module:DiscussionDescriptor", + "course_info = xmodule.html_module:CourseInfoDescriptor", + "static_tab = xmodule.html_module:StaticTabDescriptor", + "custom_tag_template = xmodule.raw_module:RawDescriptor", + "about = xmodule.html_module:AboutDescriptor" ] } ) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d75e0ff860..ea17c23bb4 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -10,7 +10,6 @@ import sys from datetime import timedelta from lxml import etree -from lxml.html import rewrite_links from pkg_resources import resource_string from capa.capa_problem import LoncapaProblem @@ -30,15 +29,17 @@ TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) def only_one(lst, default="", process=lambda x: x): """ If lst is empty, returns default - If lst has a single element, applies process to that element and returns it - Otherwise, raises an exeception + + If lst has a single element, applies process to that element and returns it. + + Otherwise, raises an exception. """ if len(lst) == 0: return default elif len(lst) == 1: return process(lst[0]) else: - raise Exception('Malformed XML') + raise Exception('Malformed XML: expected at most one element in list.') def parse_timedelta(time_str): @@ -120,6 +121,8 @@ class CapaModule(XModule): self.show_answer = self.metadata.get('showanswer', 'closed') + self.force_save_button = self.metadata.get('force_save_button', 'false') + if self.show_answer == "": self.show_answer = "closed" @@ -290,11 +293,11 @@ class CapaModule(XModule): # check button is context-specific. # Put a "Check" button if unlimited attempts or still some left - if self.max_attempts is None or self.attempts < self.max_attempts-1: + if self.max_attempts is None or self.attempts < self.max_attempts-1: check_button = "Check" else: # Will be final check so let user know that - check_button = "Final Check" + check_button = "Final Check" reset_button = True save_button = True @@ -320,9 +323,10 @@ class CapaModule(XModule): if not self.lcp.done: reset_button = False - # We don't need a "save" button if infinite number of attempts and - # non-randomized - if self.max_attempts is None and self.rerandomize != "always": + # We may not need a "save" button if infinite number of attempts and + # non-randomized. The problem author can force it. It's a bit weird for + # randomization to control this; should perhaps be cleaned up. + if (self.force_save_button == "false") and (self.max_attempts is None and self.rerandomize != "always"): save_button = False context = {'problem': content, @@ -342,17 +346,6 @@ class CapaModule(XModule): html = '
    '.format( id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "
    " - # cdodge: OK, we have to do two rounds of url reference subsitutions - # one which uses the 'asset library' that is served by the contentstore and the - # more global /static/ filesystem based static content. - # NOTE: rewrite_content_links is defined in XModule - # This is a bit unfortunate and I'm sure we'll try to considate this into - # a one step process. - try: - html = rewrite_links(html, self.rewrite_content_links) - except: - logging.error('error rewriting links in {0}'.format(html)) - # now do the substitutions which are filesystem based, e.g. '/static/' prefixes return self.system.replace_urls(html, self.metadata['data_dir']) @@ -527,26 +520,20 @@ class CapaModule(XModule): # Problem queued. Students must wait a specified waittime before they are allowed to submit if self.lcp.is_queued(): current_time = datetime.datetime.now() - prev_submit_time = self.lcp.get_recentmost_queuetime() + prev_submit_time = self.lcp.get_recentmost_queuetime() waittime_between_requests = self.system.xqueue['waittime'] if (current_time-prev_submit_time).total_seconds() < waittime_between_requests: msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests - return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback + return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback try: old_state = self.lcp.get_state() lcp_id = self.lcp.problem_id correct_map = self.lcp.grade_answers(answers) except StudentInputError as inst: - # TODO (vshnayder): why is this line here? - #self.lcp = LoncapaProblem(self.definition['data'], - # id=lcp_id, state=old_state, system=self.system) log.exception("StudentInputError in capa_module:problem_check") return {'success': inst.message} except Exception, err: - # TODO: why is this line here? - #self.lcp = LoncapaProblem(self.definition['data'], - # id=lcp_id, state=old_state, system=self.system) if self.system.DEBUG: msg = "Error checking problem: " + str(err) msg += '\nTraceback:\n' + traceback.format_exc() @@ -678,10 +665,10 @@ class CapaDescriptor(RawDescriptor): 'problems/' + path[8:], path[8:], ] - + def __init__(self, *args, **kwargs): super(CapaDescriptor, self).__init__(*args, **kwargs) - + weight_string = self.metadata.get('weight', None) if weight_string: self.weight = float(weight_string) diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py index 6dcf70fbe1..a7a76fa242 100644 --- a/common/lib/xmodule/xmodule/contentstore/content.py +++ b/common/lib/xmodule/xmodule/contentstore/content.py @@ -62,6 +62,13 @@ class StaticContent(object): @staticmethod def get_id_from_path(path): return get_id_from_location(get_location_from_path(path)) + + @staticmethod + def convert_legacy_static_url(path, course_namespace): + loc = StaticContent.compute_location(course_namespace.org, course_namespace.course, path) + return StaticContent.get_url_path_from_location(loc) + + class ContentStore(object): diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index a1549185b1..e4d2961723 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -30,13 +30,13 @@ class CourseDescriptor(SequenceDescriptor): self.book_url = book_url self.table_of_contents = self._get_toc_from_s3() self.start_page = int(self.table_of_contents[0].attrib['page']) - + # The last page should be the last element in the table of contents, # but it may be nested. So recurse all the way down the last element last_el = self.table_of_contents[-1] while last_el.getchildren(): last_el = last_el[-1] - + self.end_page = int(last_el.attrib['page']) @property @@ -94,6 +94,7 @@ class CourseDescriptor(SequenceDescriptor): self.enrollment_start = self._try_parse_time("enrollment_start") self.enrollment_end = self._try_parse_time("enrollment_end") + self.end = self._try_parse_time("end") # NOTE: relies on the modulestore to call set_grading_policy() right after # init. (Modulestore is in charge of figuring out where to load the policy from) @@ -237,6 +238,16 @@ class CourseDescriptor(SequenceDescriptor): return definition + def has_ended(self): + """ + Returns True if the current time is after the specified course end date. + Returns False if there is no end date specified. + """ + if self.end is None: + return False + + return time.gmtime() > self.end + def has_started(self): return time.gmtime() > self.start @@ -255,6 +266,10 @@ class CourseDescriptor(SequenceDescriptor): """ return self.metadata.get('tabs') + @tabs.setter + def tabs(self, value): + self.metadata['tabs'] = value + @property def show_calculator(self): return self.metadata.get("show_calculator", None) == "Yes" @@ -346,7 +361,12 @@ class CourseDescriptor(SequenceDescriptor): @property def start_date_text(self): - return time.strftime("%b %d, %Y", self.start) + displayed_start = self._try_parse_time('advertised_start') or self.start + return time.strftime("%b %d, %Y", displayed_start) + + @property + def end_date_text(self): + return time.strftime("%b %d, %Y", self.end) # An extra property is used rather than the wiki_slug/number because # there are courses that change the number for different runs. This allows @@ -374,6 +394,21 @@ class CourseDescriptor(SequenceDescriptor): more sensible framework later.""" return self.metadata.get('discussion_link', None) + @property + def forum_posts_allowed(self): + try: + blackout_periods = [(parse_time(start), parse_time(end)) + for start, end + in self.metadata.get('discussion_blackouts', [])] + now = time.gmtime() + for start, end in blackout_periods: + if start <= now <= end: + return False + except: + log.exception("Error parsing discussion_blackouts for course {0}".format(self.id)) + + return True + @property def hide_progress_tab(self): """TODO: same as above, intended to let internal CS50 hide the progress tab @@ -381,6 +416,16 @@ class CourseDescriptor(SequenceDescriptor): # Explicit comparison to True because we always want to return a bool. return self.metadata.get('hide_progress_tab') == True + @property + def end_of_course_survey_url(self): + """ + Pull from policy. Once we have our own survey module set up, can change this to point to an automatically + created survey for each class. + + Returns None if no url specified. + """ + return self.metadata.get('end_of_course_survey_url') + @property def title(self): return self.display_name @@ -394,3 +439,4 @@ class CourseDescriptor(SequenceDescriptor): return self.location.org + diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index af204b2cc1..bf575e74a3 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -359,6 +359,34 @@ div.video { } } + a.quality_control { + background: url(../images/hd.png) center no-repeat; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555); + color: #797979; + display: block; + float: left; + line-height: 46px; //height of play pause buttons + margin-left: 0; + padding: 0 lh(.5); + text-indent: -9999px; + @include transition(); + width: 30px; + + &:hover { + background-color: #444; + color: #fff; + text-decoration: none; + } + + &.active { + background-color: #F44; + color: #0ff; + text-decoration: none; + } + } + + a.hide-subtitles { background: url('../images/cc.png') center no-repeat; color: #797979; diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 00577912c8..cae099845a 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -4,7 +4,6 @@ import logging import os import sys from lxml import etree -from lxml.html import rewrite_links from path import path from .x_module import XModule @@ -29,14 +28,7 @@ class HtmlModule(XModule): js_module_name = "HTMLModule" def get_html(self): - # cdodge: perform link substitutions for any references to course static content (e.g. images) - _html = self.html - try: - _html = rewrite_links(_html, self.rewrite_content_links) - except: - logging.error('error rewriting links on the following HTML content: {0}'.format(_html)) - - return _html + return self.html def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): @@ -178,3 +170,25 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): elt = etree.Element('html') elt.set("filename", relname) return elt + + +class AboutDescriptor(HtmlDescriptor): + """ + These pieces of course content are treated as HtmlModules but we need to overload where the templates are located + in order to be able to create new ones + """ + template_dir_name = "about" + +class StaticTabDescriptor(HtmlDescriptor): + """ + These pieces of course content are treated as HtmlModules but we need to overload where the templates are located + in order to be able to create new ones + """ + template_dir_name = "statictab" + +class CourseInfoDescriptor(HtmlDescriptor): + """ + These pieces of course content are treated as HtmlModules but we need to overload where the templates are located + in order to be able to create new ones + """ + template_dir_name = "courseinfo" diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index cc389c3fc9..1c0ace9e59 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -216,7 +216,9 @@ class @Problem for choice in value @$("label[for='input_#{key}_#{choice}']").attr correct_answer: 'true' else - @$("#answer_#{key}, #solution_#{key}").html(value) + answer = @$("#answer_#{key}, #solution_#{key}") + answer.html(value) + Collapsible.setCollapsibles(answer) # TODO remove the above once everything is extracted into its own # inputtype functions. diff --git a/common/lib/xmodule/xmodule/js/src/capa/imageinput.js b/common/lib/xmodule/xmodule/js/src/capa/imageinput.js index 5b4978ee11..173cb50f54 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/imageinput.js +++ b/common/lib/xmodule/xmodule/js/src/capa/imageinput.js @@ -11,8 +11,8 @@ function image_input_click(id,event){ iidiv = document.getElementById("imageinput_"+id); - pos_x = event.offsetX?(event.offsetX):event.pageX-document.iidiv.offsetLeft; - pos_y = event.offsetY?(event.offsetY):event.pageY-document.iidiv.offsetTop; + pos_x = event.offsetX?(event.offsetX):event.pageX-iidiv.offsetLeft; + pos_y = event.offsetY?(event.offsetY):event.pageY-iidiv.offsetTop; result = "[" + pos_x + "," + pos_y + "]"; cx = (pos_x-15) +"px"; cy = (pos_y-15) +"px" ; diff --git a/common/lib/xmodule/xmodule/js/src/capa/schematic.js b/common/lib/xmodule/xmodule/js/src/capa/schematic.js index b01f6e12e8..b033dbaf46 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/schematic.js +++ b/common/lib/xmodule/xmodule/js/src/capa/schematic.js @@ -1995,7 +1995,7 @@ cktsim = (function() { // set up each schematic entry widget function update_schematics() { // set up each schematic on the page - var schematics = document.getElementsByClassName('schematic'); + var schematics = $('.schematic'); for (var i = 0; i < schematics.length; ++i) if (schematics[i].getAttribute("loaded") != "true") { try { @@ -2036,7 +2036,7 @@ function add_schematic_handler(other_onload) { // ask each schematic input widget to update its value field for submission function prepare_schematics() { - var schematics = document.getElementsByClassName('schematic'); + var schematics = $('.schematic'); for (var i = schematics.length - 1; i >= 0; i--) schematics[i].schematic.update_value(); } @@ -3339,23 +3339,28 @@ schematic = (function() { } // add method to canvas to compute relative coords for event - HTMLCanvasElement.prototype.relMouseCoords = function(event){ - // run up the DOM tree to figure out coords for top,left of canvas - var totalOffsetX = 0; - var totalOffsetY = 0; - var currentElement = this; - do { - totalOffsetX += currentElement.offsetLeft; - totalOffsetY += currentElement.offsetTop; - } - while (currentElement = currentElement.offsetParent); - - // now compute relative position of click within the canvas - this.mouse_x = event.pageX - totalOffsetX; - this.mouse_y = event.pageY - totalOffsetY; - - this.page_x = event.pageX; - this.page_y = event.pageY; + try { + if (HTMLCanvasElement) + HTMLCanvasElement.prototype.relMouseCoords = function(event){ + // run up the DOM tree to figure out coords for top,left of canvas + var totalOffsetX = 0; + var totalOffsetY = 0; + var currentElement = this; + do { + totalOffsetX += currentElement.offsetLeft; + totalOffsetY += currentElement.offsetTop; + } + while (currentElement = currentElement.offsetParent); + + // now compute relative position of click within the canvas + this.mouse_x = event.pageX - totalOffsetX; + this.mouse_y = event.pageY - totalOffsetY; + + this.page_x = event.pageX; + this.page_y = event.pageY; + } + } + catch (err) { // ignore } /////////////////////////////////////////////////////////////////////////////// @@ -4091,48 +4096,52 @@ schematic = (function() { // add dashed lines! // from http://davidowens.wordpress.com/2010/09/07/html-5-canvas-and-dashed-lines/ - CanvasRenderingContext2D.prototype.dashedLineTo = function(fromX, fromY, toX, toY, pattern) { - // Our growth rate for our line can be one of the following: - // (+,+), (+,-), (-,+), (-,-) - // Because of this, our algorithm needs to understand if the x-coord and - // y-coord should be getting smaller or larger and properly cap the values - // based on (x,y). - var lt = function (a, b) { return a <= b; }; - var gt = function (a, b) { return a >= b; }; - var capmin = function (a, b) { return Math.min(a, b); }; - var capmax = function (a, b) { return Math.max(a, b); }; - - var checkX = { thereYet: gt, cap: capmin }; - var checkY = { thereYet: gt, cap: capmin }; - - if (fromY - toY > 0) { - checkY.thereYet = lt; - checkY.cap = capmax; - } - if (fromX - toX > 0) { - checkX.thereYet = lt; - checkX.cap = capmax; - } - - this.moveTo(fromX, fromY); - var offsetX = fromX; - var offsetY = fromY; - var idx = 0, dash = true; - while (!(checkX.thereYet(offsetX, toX) && checkY.thereYet(offsetY, toY))) { - var ang = Math.atan2(toY - fromY, toX - fromX); - var len = pattern[idx]; - - offsetX = checkX.cap(toX, offsetX + (Math.cos(ang) * len)); - offsetY = checkY.cap(toY, offsetY + (Math.sin(ang) * len)); - - if (dash) this.lineTo(offsetX, offsetY); - else this.moveTo(offsetX, offsetY); - - idx = (idx + 1) % pattern.length; - dash = !dash; - } - }; - + try { + if (CanvasRenderingContext2D) + CanvasRenderingContext2D.prototype.dashedLineTo = function(fromX, fromY, toX, toY, pattern) { + // Our growth rate for our line can be one of the following: + // (+,+), (+,-), (-,+), (-,-) + // Because of this, our algorithm needs to understand if the x-coord and + // y-coord should be getting smaller or larger and properly cap the values + // based on (x,y). + var lt = function (a, b) { return a <= b; }; + var gt = function (a, b) { return a >= b; }; + var capmin = function (a, b) { return Math.min(a, b); }; + var capmax = function (a, b) { return Math.max(a, b); }; + + var checkX = { thereYet: gt, cap: capmin }; + var checkY = { thereYet: gt, cap: capmin }; + + if (fromY - toY > 0) { + checkY.thereYet = lt; + checkY.cap = capmax; + } + if (fromX - toX > 0) { + checkX.thereYet = lt; + checkX.cap = capmax; + } + + this.moveTo(fromX, fromY); + var offsetX = fromX; + var offsetY = fromY; + var idx = 0, dash = true; + while (!(checkX.thereYet(offsetX, toX) && checkY.thereYet(offsetY, toY))) { + var ang = Math.atan2(toY - fromY, toX - fromX); + var len = pattern[idx]; + + offsetX = checkX.cap(toX, offsetX + (Math.cos(ang) * len)); + offsetY = checkY.cap(toY, offsetY + (Math.sin(ang) * len)); + + if (dash) this.lineTo(offsetX, offsetY); + else this.moveTo(offsetX, offsetY); + + idx = (idx + 1) % pattern.length; + dash = !dash; + } + }; + } + catch (err) { //noop + } // given a range of values, return a new range [vmin',vmax'] where the limits // have been chosen "nicely". Taken from matplotlib.ticker.LinearLocator function view_limits(vmin,vmax) { diff --git a/common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee b/common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee new file mode 100644 index 0000000000..951eb42fce --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee @@ -0,0 +1,133 @@ +class @SelfAssessment + constructor: (element) -> + @el = $(element).find('section.self-assessment') + @id = @el.data('id') + @ajax_url = @el.data('ajax-url') + @state = @el.data('state') + @allow_reset = @el.data('allow_reset') + # valid states: 'initial', 'assessing', 'request_hint', 'done' + + # Where to put the rubric once we load it + @errors_area = @$('.error') + @answer_area = @$('textarea.answer') + + @rubric_wrapper = @$('.rubric-wrapper') + @hint_wrapper = @$('.hint-wrapper') + @message_wrapper = @$('.message-wrapper') + @submit_button = @$('.submit-button') + @reset_button = @$('.reset-button') + @reset_button.click @reset + + @find_assessment_elements() + @find_hint_elements() + + @rebind() + + # locally scoped jquery. + $: (selector) -> + $(selector, @el) + + rebind: () => + # rebind to the appropriate function for the current state + @submit_button.unbind('click') + @submit_button.show() + @reset_button.hide() + @hint_area.attr('disabled', false) + if @state == 'initial' + @answer_area.attr("disabled", false) + @submit_button.prop('value', 'Submit') + @submit_button.click @save_answer + else if @state == 'assessing' + @answer_area.attr("disabled", true) + @submit_button.prop('value', 'Submit assessment') + @submit_button.click @save_assessment + else if @state == 'request_hint' + @answer_area.attr("disabled", true) + @submit_button.prop('value', 'Submit hint') + @submit_button.click @save_hint + else if @state == 'done' + @answer_area.attr("disabled", true) + @hint_area.attr('disabled', true) + @submit_button.hide() + if @allow_reset + @reset_button.show() + else + @reset_button.hide() + + + find_assessment_elements: -> + @assessment = @$('select.assessment') + + find_hint_elements: -> + @hint_area = @$('textarea.hint') + + save_answer: (event) => + event.preventDefault() + if @state == 'initial' + data = {'student_answer' : @answer_area.val()} + $.postWithPrefix "#{@ajax_url}/save_answer", data, (response) => + if response.success + @rubric_wrapper.html(response.rubric_html) + @state = 'assessing' + @find_assessment_elements() + @rebind() + else + @errors_area.html(response.error) + else + @errors_area.html('Problem state got out of sync. Try reloading the page.') + + save_assessment: (event) => + event.preventDefault() + if @state == 'assessing' + data = {'assessment' : @assessment.find(':selected').text()} + $.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) => + if response.success + @state = response.state + + if @state == 'request_hint' + @hint_wrapper.html(response.hint_html) + @find_hint_elements() + else if @state == 'done' + @message_wrapper.html(response.message_html) + @allow_reset = response.allow_reset + + @rebind() + else + @errors_area.html(response.error) + else + @errors_area.html('Problem state got out of sync. Try reloading the page.') + + + save_hint: (event) => + event.preventDefault() + if @state == 'request_hint' + data = {'hint' : @hint_area.val()} + + $.postWithPrefix "#{@ajax_url}/save_hint", data, (response) => + if response.success + @message_wrapper.html(response.message_html) + @state = 'done' + @allow_reset = response.allow_reset + @rebind() + else + @errors_area.html(response.error) + else + @errors_area.html('Problem state got out of sync. Try reloading the page.') + + + reset: (event) => + event.preventDefault() + if @state == 'done' + $.postWithPrefix "#{@ajax_url}/reset", {}, (response) => + if response.success + @answer_area.html('') + @rubric_wrapper.html('') + @hint_wrapper.html('') + @message_wrapper.html('') + @state = 'initial' + @rebind() + @reset_button.hide() + else + @errors_area.html(response.error) + else + @errors_area.html('Problem state got out of sync. Try reloading the page.') diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee index b1e41afc3c..cdd74c5d07 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee @@ -22,7 +22,7 @@ class @VideoCaption extends Subview """ @$('.video-controls .secondary-controls').append """ Captions - """ + """#" @$('.subtitles').css maxHeight: @$('.video-wrapper').height() - 5 @fetchCaption() @@ -144,7 +144,7 @@ class @VideoCaption extends Subview @el.removeClass('closed') @scrollCaption() $.cookie('hide_captions', hide_captions, expires: 3650, path: '/') - + captionHeight: -> if @el.hasClass('fullscreen') $(window).height() - @$('.video-controls').height() diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee index 5053f1dcb1..856549c3e2 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee @@ -16,7 +16,7 @@ class @VideoControl extends Subview Fill Browser
    - """ + """#" unless onTouchBasedDevice() @$('.video_control').addClass('play').html('Play') diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee index bb89def63d..8829e25dac 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee @@ -9,6 +9,7 @@ class @VideoPlayer extends Subview bind: -> $(@control).bind('play', @play) .bind('pause', @pause) + $(@qualityControl).bind('changeQuality', @handlePlaybackQualityChange) $(@caption).bind('seek', @onSeek) $(@speedControl).bind('speedChange', @onSpeedChange) $(@progressSlider).bind('seek', @onSeek) @@ -25,6 +26,7 @@ class @VideoPlayer extends Subview render: -> @control = new VideoControl el: @$('.video-controls') + @qualityControl = new VideoQualityControl el: @$('.secondary-controls') @caption = new VideoCaption el: @el youtubeId: @video.youtubeId('1.0') @@ -41,10 +43,12 @@ class @VideoPlayer extends Subview rel: 0 showinfo: 0 enablejsapi: 1 + modestbranding: 1 videoId: @video.youtubeId() events: onReady: @onReady onStateChange: @onStateChange + onPlaybackQualityChange: @onPlaybackQualityChange @caption.hideCaptions(@['video'].hide_captions) addToolTip: -> @@ -53,7 +57,7 @@ class @VideoPlayer extends Subview my: 'top right' at: 'top center' - onReady: => + onReady: (event) => unless onTouchBasedDevice() $('.video-load-complete:first').data('video').player.play() @@ -68,6 +72,13 @@ class @VideoPlayer extends Subview when YT.PlayerState.ENDED @onEnded() + onPlaybackQualityChange: (event, value) => + quality = @player.getPlaybackQuality() + @qualityControl.onQualityChange(quality) + + handlePlaybackQualityChange: (event, value) => + @player.setPlaybackQuality(value) + onUnstarted: => @control.pause() @caption.pause() diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_quality_control.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_quality_control.coffee new file mode 100644 index 0000000000..f8f6167075 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_quality_control.coffee @@ -0,0 +1,26 @@ +class @VideoQualityControl extends Subview + initialize: -> + @quality = null; + + bind: -> + @$('.quality_control').click @toggleQuality + + render: -> + @el.append """ + HD + """#" + + onQualityChange: (value) -> + @quality = value + if @quality in ['hd720', 'hd1080', 'highres'] + @el.addClass('active') + else + @el.removeClass('active') + + toggleQuality: (event) => + event.preventDefault() + if @quality in ['hd720', 'hd1080', 'highres'] + newQuality = 'large' + else + newQuality = 'hd720' + $(@).trigger('changeQuality', newQuality) \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee index 10d3f6b044..096b50042d 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee @@ -17,7 +17,7 @@ class @VideoVolumeControl extends Subview
    - """ + """#" @slider = @$('.volume-slider').slider orientation: "vertical" range: "min" diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index fcbc2d0fbd..55236b4f8e 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -377,6 +377,7 @@ class ModuleStore(object): return courses + class ModuleStoreBase(ModuleStore): ''' Implement interface functionality that can be shared. diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 164bfd3590..19f506906c 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -1,5 +1,6 @@ import pymongo import sys +import logging from bson.son import SON from fs.osfs import OSFS @@ -275,10 +276,49 @@ class MongoModuleStore(ModuleStoreBase): source_item = self.collection.find_one(location_to_query(source)) source_item['_id'] = Location(location).dict() self.collection.insert(source_item) - return self._load_items([source_item])[0] + item = self._load_items([source_item])[0] + + # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so + # if we add one then we need to also add it to the policy information (i.e. metadata) + # we should remove this once we can break this reference from the course to static tabs + if location.category == 'static_tab': + course = self.get_course_for_item(item.location) + existing_tabs = course.tabs or [] + existing_tabs.append({'type':'static_tab', 'name' : item.metadata.get('display_name'), 'url_slug' : item.location.name}) + course.tabs = existing_tabs + self.update_metadata(course.location, course.metadata) + + return item except pymongo.errors.DuplicateKeyError: raise DuplicateItemError(location) + + def get_course_for_item(self, location): + ''' + VS[compat] + cdodge: for a given Xmodule, return the course that it belongs to + NOTE: This makes a lot of assumptions about the format of the course location + Also we have to assert that this module maps to only one course item - it'll throw an + assert if not + This is only used to support static_tabs as we need to be course module aware + ''' + + # @hack! We need to find the course location however, we don't + # know the 'name' parameter in this context, so we have + # to assume there's only one item in this query even though we are not specifying a name + course_search_location = ['i4x', location.org, location.course, 'course', None] + courses = self.get_items(course_search_location) + + # make sure we found exactly one match on this above course search + found_cnt = len(courses) + if found_cnt == 0: + raise BaseException('Could not find course at {0}'.format(course_search_location)) + + if found_cnt > 1: + raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) + + return courses[0] + def _update_single_item(self, location, update): """ Set update on the specified item, and raises ItemNotFoundError @@ -326,6 +366,19 @@ class MongoModuleStore(ModuleStoreBase): location: Something that can be passed to Location metadata: A nested dictionary of module metadata """ + # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so + # if we add one then we need to also add it to the policy information (i.e. metadata) + # we should remove this once we can break this reference from the course to static tabs + loc = Location(location) + if loc.category == 'static_tab': + course = self.get_course_for_item(loc) + existing_tabs = course.tabs or [] + for tab in existing_tabs: + if tab.get('url_slug') == loc.name: + tab['name'] = metadata.get('display_name') + break + course.tabs = existing_tabs + self.update_metadata(course.location, course.metadata) self._update_single_item(location, {'metadata': metadata}) @@ -335,6 +388,16 @@ class MongoModuleStore(ModuleStoreBase): location: Something that can be passed to Location """ + # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so + # if we add one then we need to also add it to the policy information (i.e. metadata) + # we should remove this once we can break this reference from the course to static tabs + if location.category == 'static_tab': + item = self.get_item(location) + course = self.get_course_for_item(item.location) + existing_tabs = course.tabs or [] + course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name] + self.update_metadata(course.location, course.metadata) + self.collection.remove({'_id': Location(location).dict()}) def get_parent_locations(self, location): diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 7990bafe7d..6794703998 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -4,6 +4,7 @@ import logging import os import re import sys +import glob from collections import defaultdict from cStringIO import StringIO @@ -17,6 +18,8 @@ from xmodule.course_module import CourseDescriptor from xmodule.mako_module import MakoDescriptorSystem from xmodule.x_module import XModuleDescriptor, XMLParsingSystem +from xmodule.html_module import HtmlDescriptor + from . import ModuleStoreBase, Location from .exceptions import ItemNotFoundError @@ -331,7 +334,6 @@ class XMLModuleStore(ModuleStoreBase): if not os.path.exists(policy_path): return {} try: - log.debug("Loading policy from {0}".format(policy_path)) with open(policy_path) as f: return json.load(f) except (IOError, ValueError) as err: @@ -386,6 +388,7 @@ class XMLModuleStore(ModuleStoreBase): if url_name: policy_dir = self.data_dir / course_dir / 'policies' / url_name policy_path = policy_dir / 'policy.json' + policy = self.load_policy(policy_path, tracker) # VS[compat]: remove once courses use the policy dirs. @@ -403,7 +406,6 @@ class XMLModuleStore(ModuleStoreBase): raise ValueError("Can't load a course without a 'url_name' " "(or 'name') set. Set url_name.") - course_id = CourseDescriptor.make_id(org, course, url_name) system = ImportSystem( self, @@ -423,9 +425,40 @@ class XMLModuleStore(ModuleStoreBase): # after we have the course descriptor. XModuleDescriptor.compute_inherited_metadata(course_descriptor) + # now import all pieces of course_info which is expected to be stored + # in /info or /info/ + self.load_extra_content(system, course_descriptor, 'course_info', self.data_dir / course_dir / 'info', course_dir, url_name) + + # now import all static tabs which are expected to be stored in + # in /tabs or /tabs/ + self.load_extra_content(system, course_descriptor, 'static_tab', self.data_dir / course_dir / 'tabs', course_dir, url_name) + + self.load_extra_content(system, course_descriptor, 'custom_tag_template', self.data_dir / course_dir / 'custom_tags', course_dir, url_name) + + self.load_extra_content(system, course_descriptor, 'about', self.data_dir / course_dir / 'about', course_dir, url_name) + log.debug('========> Done with course import from {0}'.format(course_dir)) return course_descriptor + def load_extra_content(self, system, course_descriptor, category, base_dir, course_dir, url_name): + if url_name: + path = base_dir / url_name + + if not os.path.exists(path): + path = base_dir + + for filepath in glob.glob(path/ '*'): + with open(filepath) as f: + try: + html = f.read().decode('utf-8') + # tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix + slug = os.path.splitext(os.path.basename(filepath))[0] + loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug) + module = HtmlDescriptor(system, definition={'data' : html}, **{'location' : loc}) + module.metadata['data_dir'] = course_dir + self.modules[course_descriptor.id][module.location] = module + except Exception, e: + logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e))) def get_instance(self, course_id, location, depth=0): """ diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 868733a99c..7ecfe61814 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -1,6 +1,8 @@ import logging import os import mimetypes +from lxml.html import rewrite_links as lxml_rewrite_links +from path import path from .xml import XMLModuleStore from .exceptions import DuplicateItemError @@ -9,29 +11,12 @@ from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX log = logging.getLogger(__name__) -def import_static_content(modules, data_dir, static_content_store): +def import_static_content(modules, course_loc, course_data_path, static_content_store, target_location_namespace): remap_dict = {} - - course_data_dir = None - course_loc = None - - # quick scan to find the course module and pull out the data_dir and location - # maybe there an easier way to look this up?!? - - for module in modules.itervalues(): - if module.category == 'course': - course_loc = module.location - course_data_dir = module.metadata['data_dir'] - - if course_data_dir is None or course_loc is None: - return remap_dict - # now import all static assets - static_dir = '{0}/static/'.format(course_data_dir) - - logging.debug("Importing static assets in {0}".format(static_dir)) + static_dir = course_data_path / 'static/' for dirname, dirnames, filenames in os.walk(static_dir): for filename in filenames: @@ -39,12 +24,11 @@ def import_static_content(modules, data_dir, static_content_store): try: content_path = os.path.join(dirname, filename) fullname_with_subpath = content_path.replace(static_dir, '') # strip away leading path from the name - content_loc = StaticContent.compute_location(course_loc.org, course_loc.course, fullname_with_subpath) + content_loc = StaticContent.compute_location(target_location_namespace.org, target_location_namespace.course, fullname_with_subpath) mime_type = mimetypes.guess_type(filename)[0] - f = open(content_path, 'rb') - data = f.read() - f.close() + with open(content_path, 'rb') as f: + data = f.read() content = StaticContent(content_loc, filename, mime_type, data) @@ -59,15 +43,52 @@ def import_static_content(modules, data_dir, static_content_store): #store the remapping information which will be needed to subsitute in the module data remap_dict[fullname_with_subpath] = content_loc.name - except: - raise + raise return remap_dict +def verify_content_links(module, base_dir, static_content_store, link, remap_dict = None): + if link.startswith('/static/'): + # yes, then parse out the name + path = link[len('/static/'):] + + static_pathname = base_dir / path + + if os.path.exists(static_pathname): + try: + content_loc = StaticContent.compute_location(module.location.org, module.location.course, path) + filename = os.path.basename(path) + mime_type = mimetypes.guess_type(filename)[0] + + with open(static_pathname, 'rb') as f: + data = f.read() + + content = StaticContent(content_loc, filename, mime_type, data) + + # first let's save a thumbnail so we can get back a thumbnail location + thumbnail_content = static_content_store.generate_thumbnail(content) + + if thumbnail_content is not None: + content.thumbnail_location = thumbnail_content.location + + #then commit the content + static_content_store.save(content) + + new_link = StaticContent.get_url_path_from_location(content_loc) + + if remap_dict is not None: + remap_dict[link] = new_link + + return new_link + except Exception, e: + logging.exception('Skipping failed content load from {0}. Exception: {1}'.format(path, e)) + + return link + def import_from_xml(store, data_dir, course_dirs=None, default_class='xmodule.raw_module.RawDescriptor', - load_error_modules=True, static_content_store=None): + load_error_modules=True, static_content_store=None, target_location_namespace = None): """ Import the specified xml data_dir into the "store" modulestore, using org and course as the location org and course. @@ -75,6 +96,11 @@ def import_from_xml(store, data_dir, course_dirs=None, course_dirs: If specified, the list of course_dirs to load. Otherwise, load all course dirs + target_location_namespace is the namespace [passed as Location] (i.e. {tag},{org},{course}) that all modules in the should be remapped to + after import off disk. We do this remapping as a post-processing step because there's logic in the importing which + expects a 'url_name' as an identifier to where things are on disk e.g. ../policies//policy.json as well as metadata keys in + the policy.json. so we need to keep the original url_name during import + """ module_store = XMLModuleStore( @@ -89,31 +115,80 @@ def import_from_xml(store, data_dir, course_dirs=None, # method on XmlModuleStore. course_items = [] for course_id in module_store.modules.keys(): - remap_dict = {} + + course_data_path = None + course_location = None + # Quick scan to get course Location as well as the course_data_path + for module in module_store.modules[course_id].itervalues(): + if module.category == 'course': + course_data_path = path(data_dir) / module.metadata['data_dir'] + course_location = module.location + if static_content_store is not None: - remap_dict = import_static_content(module_store.modules[course_id], data_dir, static_content_store) + import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store, + target_location_namespace if target_location_namespace is not None else module_store.modules[course_id].location) for module in module_store.modules[course_id].itervalues(): + # remap module to the new namespace + if target_location_namespace is not None: + # This looks a bit wonky as we need to also change the 'name' of the imported course to be what + # the caller passed in + if module.location.category != 'course': + module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course) + else: + module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course, name=target_location_namespace.name) + + # then remap children pointers since they too will be re-namespaced + children_locs = module.definition.get('children') + if children_locs is not None: + new_locs = [] + for child in children_locs: + child_loc = Location(child) + new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course) + + new_locs.append(new_child_loc.url()) + + module.definition['children'] = new_locs + + if module.category == 'course': # HACK: for now we don't support progress tabs. There's a special metadata configuration setting for this. module.metadata['hide_progress_tab'] = True + + # a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg + # so let's make sure we import in case there are no other references to it in the modules + verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg') + course_items.append(module) if 'data' in module.definition: module_data = module.definition['data'] - # cdodge: update any references to the static content paths - # This is a bit brute force - simple search/replace - but it's unlikely that such references to '/static/....' - # would occur naturally (in the wild) - # @TODO, sorry a bit of technical debt here. There are some helper methods in xmodule_modifiers.py and static_replace.py which could - # better do the url replace on the html rendering side rather than on the ingest side - try: - if '/static/' in module_data: - for subkey in remap_dict.keys(): - module_data = module_data.replace('/static/' + subkey, 'xasset:' + remap_dict[subkey]) - except: - pass # part of the techincal debt is that module_data might not be a string (e.g. ABTest) + # cdodge: now go through any link references to '/static/' and make sure we've imported + # it as a StaticContent asset + try: + remap_dict = {} + + # use the rewrite_links as a utility means to enumerate through all links + # in the module data. We use that to load that reference into our asset store + # IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to + # do the rewrites natively in that code. + # For example, what I'm seeing is -> + # Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's + # no good, so we have to do this kludge + if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code + lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path, + static_content_store, link, remap_dict)) + + for key in remap_dict.keys(): + module_data = module_data.replace(key, remap_dict[key]) + + except Exception, e: + logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location)) store.update_item(module.location, module_data) @@ -125,4 +200,6 @@ def import_from_xml(store, data_dir, course_dirs=None, # inherited metadata everywhere. store.update_metadata(module.location, dict(module.own_metadata)) + + return module_store, course_items diff --git a/common/lib/xmodule/xmodule/self_assessment_module.py b/common/lib/xmodule/xmodule/self_assessment_module.py new file mode 100644 index 0000000000..2edf5467b2 --- /dev/null +++ b/common/lib/xmodule/xmodule/self_assessment_module.py @@ -0,0 +1,476 @@ +""" +A Self Assessment module that allows students to write open-ended responses, +submit, then see a rubric and rate themselves. Persists student supplied +hints, answers, and assessment judgment (currently only correct/incorrect). +Parses xml definition file--see below for exact format. +""" + +import copy +from fs.errors import ResourceNotFoundError +import logging +import os +import sys +from lxml import etree +from lxml.html import rewrite_links +from path import path +import json +from progress import Progress + +from pkg_resources import resource_string + +from .capa_module import only_one, ComplexEncoder +from .editing_module import EditingDescriptor +from .html_checker import check_html +from .stringify import stringify_children +from .x_module import XModule +from .xml_module import XmlDescriptor +from xmodule.modulestore import Location + +log = logging.getLogger("mitx.courseware") + +# Set the default number of max attempts. Should be 1 for production +# Set higher for debugging/testing +# attempts specified in xml definition overrides this. +MAX_ATTEMPTS = 1 + +# Set maximum available number of points. +# Overriden by max_score specified in xml. +MAX_SCORE = 1 + +class SelfAssessmentModule(XModule): + """ + States: + + initial (prompt, textbox shown) + | + assessing (read-only textbox, rubric + assessment input shown) + | + request_hint (read-only textbox, read-only rubric and assessment, hint input box shown) + | + done (submitted msg, green checkmark, everything else read-only. If attempts < max, shows + a reset button that goes back to initial state. Saves previous + submissions too.) + """ + + # states + INITIAL = 'initial' + ASSESSING = 'assessing' + REQUEST_HINT = 'request_hint' + DONE = 'done' + + js = {'coffee': [resource_string(__name__, 'js/src/selfassessment/display.coffee')]} + js_module_name = "SelfAssessment" + + def __init__(self, system, location, definition, descriptor, + instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, descriptor, + instance_state, shared_state, **kwargs) + + """ + Definition file should have 4 blocks -- prompt, rubric, submitmessage, hintprompt, + and two optional attributes: + attempts, which should be an integer that defaults to 1. + If it's > 1, the student will be able to re-submit after they see + the rubric. + max_score, which should be an integer that defaults to 1. + It defines the maximum number of points a student can get. Assumed to be integer scale + from 0 to max_score, with an interval of 1. + + Note: all the submissions are stored. + + Sample file: + + + + Insert prompt text here. (arbitrary html) + + + Insert grading rubric here. (arbitrary html) + + + Please enter a hint below: (arbitrary html) + + + Thanks for submitting! (arbitrary html) + + + """ + + # Load instance state + if instance_state is not None: + instance_state = json.loads(instance_state) + else: + instance_state = {} + + # Note: score responses are on scale from 0 to max_score + self.student_answers = instance_state.get('student_answers', []) + self.scores = instance_state.get('scores', []) + self.hints = instance_state.get('hints', []) + + self.state = instance_state.get('state', 'initial') + + # Used for progress / grading. Currently get credit just for + # completion (doesn't matter if you self-assessed correct/incorrect). + + self._max_score = int(self.metadata.get('max_score', MAX_SCORE)) + + self.attempts = instance_state.get('attempts', 0) + + self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS)) + + self.rubric = definition['rubric'] + self.prompt = definition['prompt'] + self.submit_message = definition['submitmessage'] + self.hint_prompt = definition['hintprompt'] + + def _allow_reset(self): + """Can the module be reset?""" + return self.state == self.DONE and self.attempts < self.max_attempts + + def get_html(self): + #set context variables and render template + if self.state != self.INITIAL and self.student_answers: + previous_answer = self.student_answers[-1] + else: + previous_answer = '' + + context = { + 'prompt': self.prompt, + 'previous_answer': previous_answer, + 'ajax_url': self.system.ajax_url, + 'initial_rubric': self.get_rubric_html(), + 'initial_hint': self.get_hint_html(), + 'initial_message': self.get_message_html(), + 'state': self.state, + 'allow_reset': self._allow_reset(), + } + html = self.system.render_template('self_assessment_prompt.html', context) + + # cdodge: perform link substitutions for any references to course static content (e.g. images) + return rewrite_links(html, self.rewrite_content_links) + + def get_score(self): + """ + Returns dict with 'score' key + """ + return {'score': self.get_last_score()} + + def max_score(self): + """ + Return max_score + """ + return self._max_score + + def get_last_score(self): + """ + Returns the last score in the list + """ + last_score=0 + if(len(self.scores)>0): + last_score=self.scores[len(self.scores)-1] + return last_score + + def get_progress(self): + ''' + For now, just return last score / max_score + ''' + if self._max_score > 0: + try: + return Progress(self.get_last_score(), self._max_score) + except Exception as err: + log.exception("Got bad progress") + return None + return None + + + def handle_ajax(self, dispatch, get): + """ + This is called by courseware.module_render, to handle an AJAX call. + "get" is request.POST. + + Returns a json dictionary: + { 'progress_changed' : True/False, + 'progress': 'none'/'in_progress'/'done', + } + """ + + handlers = { + 'save_answer': self.save_answer, + 'save_assessment': self.save_assessment, + 'save_hint': self.save_hint, + 'reset': self.reset, + } + + if dispatch not in handlers: + return 'Error' + + before = self.get_progress() + d = handlers[dispatch](get) + after = self.get_progress() + d.update({ + 'progress_changed': after != before, + 'progress_status': Progress.to_js_status_str(after), + }) + return json.dumps(d, cls=ComplexEncoder) + + def out_of_sync_error(self, get, msg=''): + """ + return dict out-of-sync error message, and also log. + """ + log.warning("Assessment module state out sync. state: %r, get: %r. %s", + self.state, get, msg) + return {'success': False, + 'error': 'The problem state got out-of-sync'} + + def get_rubric_html(self): + """ + Return the appropriate version of the rubric, based on the state. + """ + if self.state == self.INITIAL: + return '' + + # we'll render it + context = {'rubric': self.rubric, + 'max_score' : self._max_score, + } + + if self.state == self.ASSESSING: + context['read_only'] = False + elif self.state in (self.REQUEST_HINT, self.DONE): + context['read_only'] = True + else: + raise ValueError("Illegal state '%r'" % self.state) + + return self.system.render_template('self_assessment_rubric.html', context) + + def get_hint_html(self): + """ + Return the appropriate version of the hint view, based on state. + """ + if self.state in (self.INITIAL, self.ASSESSING): + return '' + + if self.state == self.DONE and len(self.hints) > 0: + # display the previous hint + hint = self.hints[-1] + else: + hint = '' + + context = {'hint_prompt': self.hint_prompt, + 'hint': hint} + + if self.state == self.REQUEST_HINT: + context['read_only'] = False + elif self.state == self.DONE: + context['read_only'] = True + else: + raise ValueError("Illegal state '%r'" % self.state) + + return self.system.render_template('self_assessment_hint.html', context) + + def get_message_html(self): + """ + Return the appropriate version of the message view, based on state. + """ + if self.state != self.DONE: + return "" + + return """
    {0}
    """.format(self.submit_message) + + + def save_answer(self, get): + """ + After the answer is submitted, show the rubric. + """ + # Check to see if attempts are less than max + if self.attempts > self.max_attempts: + # If too many attempts, prevent student from saving answer and + # seeing rubric. In normal use, students shouldn't see this because + # they won't see the reset button once they're out of attempts. + return { + 'success': False, + 'error': 'Too many attempts.' + } + + if self.state != self.INITIAL: + return self.out_of_sync_error(get) + + self.student_answers.append(get['student_answer']) + self.state = self.ASSESSING + + return { + 'success': True, + 'rubric_html': self.get_rubric_html() + } + + def save_assessment(self, get): + """ + Save the assessment. If the student said they're right, don't ask for a + hint, and go straight to the done state. Otherwise, do ask for a hint. + + Returns a dict { 'success': bool, 'state': state, + + 'hint_html': hint_html OR 'message_html': html and 'allow_reset', + + 'error': error-msg}, + + with 'error' only present if 'success' is False, and 'hint_html' or + 'message_html' only if success is true + """ + + n_answers = len(self.student_answers) + n_scores = len(self.scores) + if (self.state != self.ASSESSING or n_answers != n_scores + 1): + msg = "%d answers, %d scores" % (n_answers, n_scores) + return self.out_of_sync_error(get, msg) + + try: + score = int(get['assessment']) + except: + return {'success': False, 'error': "Non-integer score value"} + + self.scores.append(score) + + d = {'success': True,} + + if score == self.max_score(): + self.state = self.DONE + d['message_html'] = self.get_message_html() + d['allow_reset'] = self._allow_reset() + else: + self.state = self.REQUEST_HINT + d['hint_html'] = self.get_hint_html() + + d['state'] = self.state + return d + + + def save_hint(self, get): + ''' + Save the hint. + Returns a dict { 'success': bool, + 'message_html': message_html, + 'error': error-msg, + 'allow_reset': bool}, + with the error key only present if success is False and message_html + only if True. + ''' + if self.state != self.REQUEST_HINT: + # Note: because we only ask for hints on wrong answers, may not have + # the same number of hints and answers. + return self.out_of_sync_error(get) + + self.hints.append(get['hint'].lower()) + self.state = self.DONE + + # increment attempts + self.attempts = self.attempts + 1 + + # To the tracking logs! + event_info = { + 'selfassessment_id': self.location.url(), + 'state': { + 'student_answers': self.student_answers, + 'score': self.scores, + 'hints': self.hints, + } + } + self.system.track_function('save_hint', event_info) + + return {'success': True, + 'message_html': self.get_message_html(), + 'allow_reset': self._allow_reset()} + + + def reset(self, get): + """ + If resetting is allowed, reset the state. + + Returns {'success': bool, 'error': msg} + (error only present if not success) + """ + if self.state != self.DONE: + return self.out_of_sync_error(get) + + if self.attempts > self.max_attempts: + return { + 'success': False, + 'error': 'Too many attempts.' + } + self.state = self.INITIAL + return {'success': True} + + + def get_instance_state(self): + """ + Get the current score and state + """ + + state = { + 'student_answers': self.student_answers, + 'hints': self.hints, + 'state': self.state, + 'scores': self.scores, + 'max_score': self._max_score, + 'attempts': self.attempts + } + return json.dumps(state) + + +class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): + """ + Module for adding self assessment questions to courses + """ + mako_template = "widgets/html-edit.html" + module_class = SelfAssessmentModule + filename_extension = "xml" + + stores_state = True + has_score = True + template_dir_name = "selfassessment" + + js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} + js_module_name = "HTMLEditingDescriptor" + + @classmethod + def definition_from_xml(cls, xml_object, system): + """ + Pull out the rubric, prompt, and submitmessage into a dictionary. + + Returns: + { + 'rubric': 'some-html', + 'prompt': 'some-html', + 'submitmessage': 'some-html' + 'hintprompt': 'some-html' + } + """ + expected_children = ['rubric', 'prompt', 'submitmessage', 'hintprompt'] + for child in expected_children: + if len(xml_object.xpath(child)) != 1: + raise ValueError("Self assessment definition must include exactly one '{0}' tag".format(child)) + + def parse(k): + """Assumes that xml_object has child k""" + return stringify_children(xml_object.xpath(k)[0]) + + return {'rubric': parse('rubric'), + 'prompt': parse('prompt'), + 'submitmessage': parse('submitmessage'), + 'hintprompt': parse('hintprompt'), + } + + + def definition_to_xml(self, resource_fs): + '''Return an xml element representing this definition.''' + elt = etree.Element('selfassessment') + + def add_child(k): + child_str = '<{tag}>{body}'.format(tag=k, body=self.definition[k]) + child_node = etree.fromstring(child_str) + elt.append(child_node) + + for child in ['rubric', 'prompt', 'submitmessage', 'hintprompt']: + add_child(child) + + return elt diff --git a/common/lib/xmodule/xmodule/template_module.py b/common/lib/xmodule/xmodule/template_module.py index 4df1c4ee3a..cca2cb0ca8 100644 --- a/common/lib/xmodule/xmodule/template_module.py +++ b/common/lib/xmodule/xmodule/template_module.py @@ -2,6 +2,7 @@ from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor from lxml import etree from mako.template import Template +from xmodule.modulestore.django import modulestore class CustomTagModule(XModule): @@ -40,8 +41,7 @@ class CustomTagDescriptor(RawDescriptor): module_class = CustomTagModule template_dir_name = 'customtag' - @staticmethod - def render_template(system, xml_data): + def render_template(self, system, xml_data): '''Render the template, given the definition xml_data''' xmltree = etree.fromstring(xml_data) if 'impl' in xmltree.attrib: @@ -57,15 +57,23 @@ class CustomTagDescriptor(RawDescriptor): .format(location)) params = dict(xmltree.items()) - with system.resources_fs.open('custom_tags/{name}' - .format(name=template_name)) as template: - return Template(template.read()).render(**params) + + # cdodge: look up the template as a module + template_loc = self.location._replace(category='custom_tag_template', name=template_name) + + template_module = modulestore().get_item(template_loc) + template_module_data = template_module.definition['data'] + template = Template(template_module_data) + return template.render(**params) def __init__(self, system, definition, **kwargs): '''Render and save the template for this descriptor instance''' super(CustomTagDescriptor, self).__init__(system, definition, **kwargs) - self.rendered_html = self.render_template(system, definition['data']) + + @property + def rendered_html(self): + return self.render_template(self.system, self.definition['data']) def export_to_file(self): """ diff --git a/common/lib/xmodule/xmodule/templates/about/empty.yaml b/common/lib/xmodule/xmodule/templates/about/empty.yaml new file mode 100644 index 0000000000..fa3ed606bd --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/about/empty.yaml @@ -0,0 +1,5 @@ +--- +metadata: + display_name: Empty +data: "

    This is where you can add additional information about your course.

    " +children: [] \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml new file mode 100644 index 0000000000..fa3ed606bd --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml @@ -0,0 +1,5 @@ +--- +metadata: + display_name: Empty +data: "

    This is where you can add additional information about your course.

    " +children: [] \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml b/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml index 6b31c4af3e..434354e4c7 100644 --- a/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml @@ -36,7 +36,7 @@ metadata: %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% \subsection{Example "multiple choice" problem} - What color is a bannana? + What color is a banana? \edXabox{ type="multichoice" expect="Yellow" options="Red","Green","Yellow","Blue" } @@ -129,7 +129,7 @@ data: |

    Example "multiple choice" problem

    - What color is a bannana?

    + What color is a banana?

    diff --git a/common/lib/xmodule/xmodule/templates/statictab/empty.yaml b/common/lib/xmodule/xmodule/templates/statictab/empty.yaml new file mode 100644 index 0000000000..410e1496c2 --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/statictab/empty.yaml @@ -0,0 +1,5 @@ +--- +metadata: + display_name: Empty +data: "

    This is where you can add additional pages to your courseware. Click the 'edit' button to begin editing.

    " +children: [] \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index 826e6c9d5a..aeebc6da6b 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -113,3 +113,7 @@ class RoundTripTestCase(unittest.TestCase): def test_full_roundtrip(self): self.check_export_roundtrip(DATA_DIR, "full") + + def test_selfassessment_roundtrip(self): + #Test selfassessment xmodule to see if it exports correctly + self.check_export_roundtrip(DATA_DIR,"self_assessment") diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 21dd8968f6..77532959d7 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -176,6 +176,33 @@ class ImportTestCase(unittest.TestCase): self.assertEqual(chapter_xml.tag, 'chapter') self.assertFalse('graceperiod' in chapter_xml.attrib) + def test_is_pointer_tag(self): + """ + Check that is_pointer_tag works properly. + """ + + yes = ["""""", + """""", + """ """, + """""", + """"""] + + no = ["""""", + """some text""", + """tree""", + """ + 3 + + """] + + for xml_str in yes: + print "should be True for {0}".format(xml_str) + self.assertTrue(is_pointer_tag(etree.fromstring(xml_str))) + + for xml_str in no: + print "should be False for {0}".format(xml_str) + self.assertFalse(is_pointer_tag(etree.fromstring(xml_str))) + def test_metadata_inherit(self): """Make sure that metadata is inherited properly""" @@ -311,3 +338,17 @@ class ImportTestCase(unittest.TestCase): system = self.get_system(False) self.assertRaises(etree.XMLSyntaxError, system.process_xml, bad_xml) + + def test_selfassessment_import(self): + ''' + Check to see if definition_from_xml in self_assessment_module.py + works properly. Pulls data from the self_assessment directory in the test data directory. + ''' + + modulestore = XMLModuleStore(DATA_DIR, course_dirs=['self_assessment']) + + sa_id = "edX/sa_test/2012_Fall" + location = Location(["i4x", "edX", "sa_test", "selfassessment", "SampleQuestion"]) + sa_sample = modulestore.get_instance(sa_id, location) + #10 attempts is hard coded into SampleQuestion, which is the url_name of a selfassessment xml tag + self.assertEqual(sa_sample.metadata['attempts'], '10') diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 11c90f45ef..9a22950ca8 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -32,6 +32,7 @@ class VideoModule(XModule): self.position = 0 self.show_captions = xmltree.get('show_captions', 'true') self.source = self._get_source(xmltree) + self.track = self._get_track(xmltree) if instance_state is not None: state = json.loads(instance_state) @@ -40,13 +41,25 @@ class VideoModule(XModule): def _get_source(self, xmltree): # find the first valid source - source = None - for element in xmltree.findall('source'): + return self._get_first_external(xmltree, 'source') + + def _get_track(self, xmltree): + # find the first valid track + return self._get_first_external(xmltree, 'track') + + def _get_first_external(self, xmltree, tag): + """ + Will return the first valid element + of the given tag. + 'valid' means has a non-empty 'src' attribute + """ + result = None + for element in xmltree.findall(tag): src = element.get('src') if src: - source = src + result = src break - return source + return result def handle_ajax(self, dispatch, get): ''' @@ -85,6 +98,7 @@ class VideoModule(XModule): 'id': self.location.html_id(), 'position': self.position, 'source': self.source, + 'track' : self.track, 'display_name': self.display_name, # TODO (cpennington): This won't work when we move to data that isn't on the filesystem 'data_dir': self.metadata['data_dir'], diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index a53f2db7da..2174d28112 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -320,21 +320,6 @@ class XModule(HTMLSnippet): get is a dictionary-like object ''' return "" - # cdodge: added to support dynamic substitutions of - # links for courseware assets (e.g. images). is passed through from lxml.html parser - def rewrite_content_links(self, link): - # see if we start with our format, e.g. 'xasset:' - if link.startswith(XASSET_SRCREF_PREFIX): - # yes, then parse out the name - name = link[len(XASSET_SRCREF_PREFIX):] - loc = Location(self.location) - # resolve the reference to our internal 'filepath' which - content_loc = StaticContent.compute_location(loc.org, loc.course, name) - link = StaticContent.get_url_path_from_location(content_loc) - - return link - - def policy_key(location): """ diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index a5b3460f17..c56803f3c4 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -25,7 +25,7 @@ def name_to_pathname(name): def is_pointer_tag(xml_obj): """ Check if xml_obj is a pointer tag: . - No children, one attribute named url_name. + No children, one attribute named url_name, no text. Special case for course roots: the pointer is @@ -40,7 +40,10 @@ def is_pointer_tag(xml_obj): expected_attr = set(['url_name', 'course', 'org']) actual_attr = set(xml_obj.attrib.keys()) - return len(xml_obj) == 0 and actual_attr == expected_attr + + has_text = xml_obj.text is not None and len(xml_obj.text.strip()) > 0 + + return len(xml_obj) == 0 and actual_attr == expected_attr and not has_text def get_metadata_from_xml(xml_object, remove=True): meta = xml_object.find('meta') @@ -93,7 +96,9 @@ class XmlDescriptor(XModuleDescriptor): # VS[compat] Remove once unused. 'name', 'slug') - metadata_to_strip = ('data_dir', + metadata_to_strip = ('data_dir', + # cdodge: @TODO: We need to figure out a way to export out 'tabs' and 'grading_policy' which is on the course + 'tabs', 'grading_policy', # VS[compat] -- remove the below attrs once everything is in the CMS 'course', 'org', 'url_name', 'filename') @@ -355,7 +360,8 @@ class XmlDescriptor(XModuleDescriptor): for attr in sorted(self.own_metadata): # don't want e.g. data_dir if attr not in self.metadata_to_strip: - xml_object.set(attr, val_for_xml(attr)) + val = val_for_xml(attr) + xml_object.set(attr, val) if self.export_to_file(): # Write the definition to a file diff --git a/common/static/images/capa/vsepr/AX2E0-3D-balls.png b/common/static/images/capa/vsepr/AX2E0-3D-balls.png new file mode 100644 index 0000000000..b5a0161dad Binary files /dev/null and b/common/static/images/capa/vsepr/AX2E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX2E1-3D-balls.png b/common/static/images/capa/vsepr/AX2E1-3D-balls.png new file mode 100644 index 0000000000..8035c953b3 Binary files /dev/null and b/common/static/images/capa/vsepr/AX2E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX2E2-3D-balls.png b/common/static/images/capa/vsepr/AX2E2-3D-balls.png new file mode 100644 index 0000000000..35ad434bb6 Binary files /dev/null and b/common/static/images/capa/vsepr/AX2E2-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX2E3-3D-balls.png b/common/static/images/capa/vsepr/AX2E3-3D-balls.png new file mode 100644 index 0000000000..7535de598b Binary files /dev/null and b/common/static/images/capa/vsepr/AX2E3-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX3E0-3D-balls.png b/common/static/images/capa/vsepr/AX3E0-3D-balls.png new file mode 100644 index 0000000000..ce52fdf916 Binary files /dev/null and b/common/static/images/capa/vsepr/AX3E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX3E1-3D-balls.png b/common/static/images/capa/vsepr/AX3E1-3D-balls.png new file mode 100644 index 0000000000..e50a46b500 Binary files /dev/null and b/common/static/images/capa/vsepr/AX3E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX3E2-3D-balls.png b/common/static/images/capa/vsepr/AX3E2-3D-balls.png new file mode 100644 index 0000000000..120dcad363 Binary files /dev/null and b/common/static/images/capa/vsepr/AX3E2-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX4E0-3D-balls.png b/common/static/images/capa/vsepr/AX4E0-3D-balls.png new file mode 100644 index 0000000000..17e5817eaf Binary files /dev/null and b/common/static/images/capa/vsepr/AX4E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX4E1-3D-balls.png b/common/static/images/capa/vsepr/AX4E1-3D-balls.png new file mode 100644 index 0000000000..4877e00576 Binary files /dev/null and b/common/static/images/capa/vsepr/AX4E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX4E2-3D-balls.png b/common/static/images/capa/vsepr/AX4E2-3D-balls.png new file mode 100644 index 0000000000..8ee822ad66 Binary files /dev/null and b/common/static/images/capa/vsepr/AX4E2-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX5E1-3D-balls.png b/common/static/images/capa/vsepr/AX5E1-3D-balls.png new file mode 100644 index 0000000000..de86f6fdaf Binary files /dev/null and b/common/static/images/capa/vsepr/AX5E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX5E2-3D-balls.png b/common/static/images/capa/vsepr/AX5E2-3D-balls.png new file mode 100644 index 0000000000..2e1db7a8e3 Binary files /dev/null and b/common/static/images/capa/vsepr/AX5E2-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX6E0-3D-balls.png b/common/static/images/capa/vsepr/AX6E0-3D-balls.png new file mode 100644 index 0000000000..d878d1dd4d Binary files /dev/null and b/common/static/images/capa/vsepr/AX6E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX6E1-3D-balls.png b/common/static/images/capa/vsepr/AX6E1-3D-balls.png new file mode 100644 index 0000000000..3734b696e0 Binary files /dev/null and b/common/static/images/capa/vsepr/AX6E1-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX7E0-3D-balls.png b/common/static/images/capa/vsepr/AX7E0-3D-balls.png new file mode 100644 index 0000000000..37935492aa Binary files /dev/null and b/common/static/images/capa/vsepr/AX7E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX8E0-3D-balls.png b/common/static/images/capa/vsepr/AX8E0-3D-balls.png new file mode 100644 index 0000000000..bbbc9608c3 Binary files /dev/null and b/common/static/images/capa/vsepr/AX8E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/AX9E0-3D-balls.png b/common/static/images/capa/vsepr/AX9E0-3D-balls.png new file mode 100644 index 0000000000..75d2449ed6 Binary files /dev/null and b/common/static/images/capa/vsepr/AX9E0-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Bent-3D-balls.png b/common/static/images/capa/vsepr/Bent-3D-balls.png new file mode 100644 index 0000000000..aec734c57c Binary files /dev/null and b/common/static/images/capa/vsepr/Bent-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Linear-3D-balls.png b/common/static/images/capa/vsepr/Linear-3D-balls.png new file mode 100644 index 0000000000..0876c8a9a5 Binary files /dev/null and b/common/static/images/capa/vsepr/Linear-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Linear-stick.png b/common/static/images/capa/vsepr/Linear-stick.png new file mode 100644 index 0000000000..d4310bedc0 Binary files /dev/null and b/common/static/images/capa/vsepr/Linear-stick.png differ diff --git a/common/static/images/capa/vsepr/Octahedral-3D-balls.png b/common/static/images/capa/vsepr/Octahedral-3D-balls.png new file mode 100644 index 0000000000..4b7809d03c Binary files /dev/null and b/common/static/images/capa/vsepr/Octahedral-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Octahedral-stick.png b/common/static/images/capa/vsepr/Octahedral-stick.png new file mode 100644 index 0000000000..1053d63b5b Binary files /dev/null and b/common/static/images/capa/vsepr/Octahedral-stick.png differ diff --git a/common/static/images/capa/vsepr/Pentagonal-bipyramidal-3D-balls.png b/common/static/images/capa/vsepr/Pentagonal-bipyramidal-3D-balls.png new file mode 100644 index 0000000000..d518717a52 Binary files /dev/null and b/common/static/images/capa/vsepr/Pentagonal-bipyramidal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Pentagonal-planar-3D-balls.png b/common/static/images/capa/vsepr/Pentagonal-planar-3D-balls.png new file mode 100644 index 0000000000..1c47a26816 Binary files /dev/null and b/common/static/images/capa/vsepr/Pentagonal-planar-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Pentagonal-pyramidal-3D-balls.png b/common/static/images/capa/vsepr/Pentagonal-pyramidal-3D-balls.png new file mode 100644 index 0000000000..b63d3cd494 Binary files /dev/null and b/common/static/images/capa/vsepr/Pentagonal-pyramidal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Pyramidal-3D-balls.png b/common/static/images/capa/vsepr/Pyramidal-3D-balls.png new file mode 100644 index 0000000000..3fb93f096f Binary files /dev/null and b/common/static/images/capa/vsepr/Pyramidal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Seesaw-3D-balls.png b/common/static/images/capa/vsepr/Seesaw-3D-balls.png new file mode 100644 index 0000000000..a6598fde77 Binary files /dev/null and b/common/static/images/capa/vsepr/Seesaw-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Square-antiprismatic-3D-balls.png b/common/static/images/capa/vsepr/Square-antiprismatic-3D-balls.png new file mode 100644 index 0000000000..e36cdce783 Binary files /dev/null and b/common/static/images/capa/vsepr/Square-antiprismatic-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Square-planar-3D-balls.png b/common/static/images/capa/vsepr/Square-planar-3D-balls.png new file mode 100644 index 0000000000..1647eab389 Binary files /dev/null and b/common/static/images/capa/vsepr/Square-planar-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/T-shaped-3D-balls.png b/common/static/images/capa/vsepr/T-shaped-3D-balls.png new file mode 100644 index 0000000000..59cac0d4f5 Binary files /dev/null and b/common/static/images/capa/vsepr/T-shaped-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Tetrahedral-3D-balls.png b/common/static/images/capa/vsepr/Tetrahedral-3D-balls.png new file mode 100644 index 0000000000..835ed26b89 Binary files /dev/null and b/common/static/images/capa/vsepr/Tetrahedral-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Tetrahedral-stick.png b/common/static/images/capa/vsepr/Tetrahedral-stick.png new file mode 100644 index 0000000000..6266bb8238 Binary files /dev/null and b/common/static/images/capa/vsepr/Tetrahedral-stick.png differ diff --git a/common/static/images/capa/vsepr/Trigonal-3D-balls.png b/common/static/images/capa/vsepr/Trigonal-3D-balls.png new file mode 100644 index 0000000000..65cca2f10f Binary files /dev/null and b/common/static/images/capa/vsepr/Trigonal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Trigonal-bipyramidal-3D-balls.png b/common/static/images/capa/vsepr/Trigonal-bipyramidal-3D-balls.png new file mode 100644 index 0000000000..1caed952ed Binary files /dev/null and b/common/static/images/capa/vsepr/Trigonal-bipyramidal-3D-balls.png differ diff --git a/common/static/images/capa/vsepr/Trigonal-bipyramidal-stick.png b/common/static/images/capa/vsepr/Trigonal-bipyramidal-stick.png new file mode 100644 index 0000000000..0f99d4fbc4 Binary files /dev/null and b/common/static/images/capa/vsepr/Trigonal-bipyramidal-stick.png differ diff --git a/common/static/images/capa/vsepr/Trigonal-planar-stick.png b/common/static/images/capa/vsepr/Trigonal-planar-stick.png new file mode 100644 index 0000000000..b73d8a90de Binary files /dev/null and b/common/static/images/capa/vsepr/Trigonal-planar-stick.png differ diff --git a/common/static/images/hd.png b/common/static/images/hd.png new file mode 100644 index 0000000000..d6b8c1b7d1 Binary files /dev/null and b/common/static/images/hd.png differ diff --git a/cms/templates/jasmine/base.html b/common/templates/jasmine/base.html similarity index 87% rename from cms/templates/jasmine/base.html rename to common/templates/jasmine/base.html index 0cbf63bb29..96507bdebf 100644 --- a/cms/templates/jasmine/base.html +++ b/common/templates/jasmine/base.html @@ -11,6 +11,7 @@ + {# source files #} {% for url in suite.js_files %} @@ -19,7 +20,7 @@ {% load compressed %} {# static files #} - {% compressed_js 'main' %} + {% compressed_js 'js-test-source' %} {# spec files #} {% compressed_js 'spec' %} @@ -31,6 +32,7 @@ + \ No newline at end of file diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 3fdcbb5c1c..2069685a6c 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -2,6 +2,7 @@ from django.core.urlresolvers import reverse from courseware.courses import course_image_url, get_course_about_section from courseware.access import has_access + from certificates.models import CertificateStatuses %> <%inherit file="main.html" /> @@ -135,8 +136,16 @@

    ${get_course_about_section(course, 'university')}

    ${course.number} ${course.title}

    -
    -

    Class Starts - ${course.start_date_text}

    +
    +

    + % if course.has_ended(): + Course Completed - ${course.end_date_text} + % elif course.has_started(): + Course Started - ${course.start_date_text} + % else: # hasn't started yet + Course Starts - ${course.start_date_text} + % endif +

    % if course.id in show_courseware_links_for:

    View Courseware

    @@ -144,6 +153,68 @@
    + + <% + cert_status = cert_statuses.get(course.id) + %> + % if course.has_ended() and cert_status: + <% + passing_grade = False + cert_button = False + survey_button = False + if cert_status['status'] in [CertificateStatuses.generating, CertificateStatuses.regenerating]: + status_css_class = 'course-status-certrendering' + cert_button = True + survey_button = True + passing_grade = True + elif cert_status['status'] == CertificateStatuses.downloadable: + status_css_class = 'course-status-certavailable' + cert_button = True + survey_button = True + passing_grade = True + elif cert_status['status'] == CertificateStatuses.notpassing: + status_css_class = 'course-status-certnotavailable' + survey_button = True + else: + # This is primarily the 'unavailable' state, but also 'error', 'deleted', etc. + status_css_class = 'course-status-processing' + + if survey_button and not course.end_of_course_survey_url: + survey_button = False + %> +
    + + % if cert_status['status'] == CertificateStatuses.unavailable: +

    Final course details are being wrapped up at this time. + Your final standing will be available shortly.

    + % elif passing_grade: +

    You have received a grade of + ${cert_status['grade']} + in this course.

    + % elif cert_status['status'] == CertificateStatuses.notpassing: +

    You did not complete the necessary requirements for completion of this course. +

    + % endif + % if cert_button or survey_button: +
      + % if cert_button and cert_status['status'] in [CertificateStatuses.generating, CertificateStatuses.regenerating]: +
    • Your Certificate is Generating
    • + % elif cert_button and cert_status['status'] == CertificateStatuses.downloadable: +
    • + + Download Your PDF Certificate
    • + % endif + % if survey_button: +
    • + Complete our course feedback survey
    • + % endif +
    + % endif +
    + + % endif + Unregister % endfor @@ -280,4 +351,3 @@ - diff --git a/lms/templates/discussion/_discussion_course_navigation.html b/lms/templates/discussion/_discussion_course_navigation.html index 13b291196b..d770cacc96 100644 --- a/lms/templates/discussion/_discussion_course_navigation.html +++ b/lms/templates/discussion/_discussion_course_navigation.html @@ -1,5 +1,8 @@ +<%! from django_comment_client.permissions import has_permission %> <%inherit file="../courseware/course_navigation.html" /> <%block name="extratabs"> +% if has_permission(user, 'create_thread', course.id):
  • New Post
  • +% endif \ No newline at end of file diff --git a/lms/templates/discussion/_discussion_module.html b/lms/templates/discussion/_discussion_module.html index bb172093f6..8fced92f7f 100644 --- a/lms/templates/discussion/_discussion_module.html +++ b/lms/templates/discussion/_discussion_module.html @@ -3,4 +3,6 @@
    Show Discussion New Post + +
    diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 0a691ac36f..be238811c2 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -1,3 +1,5 @@ +<%! from django_comment_client.permissions import has_permission %> + @@ -75,6 +79,7 @@
    1. + % if course is UNDEFINED or has_permission(user, 'create_sub_comment', course.id):
        Submit
        + % endif
      diff --git a/lms/templates/google_analytics.html b/lms/templates/google_analytics.html new file mode 100644 index 0000000000..273fbac970 --- /dev/null +++ b/lms/templates/google_analytics.html @@ -0,0 +1,11 @@ + diff --git a/lms/templates/jasmine/base.html b/lms/templates/jasmine/base.html deleted file mode 100644 index 199af334f9..0000000000 --- a/lms/templates/jasmine/base.html +++ /dev/null @@ -1,67 +0,0 @@ - - - - - Jasmine Spec Runner - - {% load staticfiles %} - - - {# core files #} - - - - - {# source files #} - {% for url in suite.js_files %} - - {% endfor %} - - {% load compressed %} - {# static files #} - {% compressed_js 'application' %} - {% compressed_js 'module-js' %} - - {# spec files #} - {% compressed_js 'spec' %} - - - - -

      Jasmine Spec Runner

      - - - - - diff --git a/lms/templates/main.html b/lms/templates/main.html index f234aa72cf..5d3fd29104 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -19,22 +19,12 @@ <%static:css group='ie-fixes'/> + % if not course: - + <%include file="google_analytics.html" /> % endif + diff --git a/lms/templates/portal/course_about.html b/lms/templates/portal/course_about.html index 6fced73565..b35c7a1b6f 100644 --- a/lms/templates/portal/course_about.html +++ b/lms/templates/portal/course_about.html @@ -7,7 +7,12 @@ <%inherit file="../main.html" /> +<%block name="headextra"> + <%include file="../google_analytics.html" /> + + <%block name="js_extra"> + % if not registered: %if user.is_authenticated(): ## If the user is authenticated, clicking the enroll button just submits a form diff --git a/lms/templates/self_assessment_hint.html b/lms/templates/self_assessment_hint.html new file mode 100644 index 0000000000..64c45b809e --- /dev/null +++ b/lms/templates/self_assessment_hint.html @@ -0,0 +1,7 @@ +
      +
      + ${hint_prompt} +
      + +
      diff --git a/lms/templates/self_assessment_prompt.html b/lms/templates/self_assessment_prompt.html new file mode 100644 index 0000000000..88549e9f56 --- /dev/null +++ b/lms/templates/self_assessment_prompt.html @@ -0,0 +1,20 @@ +
      +
      +
      + ${prompt} +
      + +
      + +
      + +
      ${initial_rubric}
      + +
      ${initial_hint}
      + +
      ${initial_message}
      + + + +
      diff --git a/lms/templates/self_assessment_rubric.html b/lms/templates/self_assessment_rubric.html new file mode 100644 index 0000000000..5bcb3bba93 --- /dev/null +++ b/lms/templates/self_assessment_rubric.html @@ -0,0 +1,15 @@ +
      +
      +

      Self-assess your answer with this rubric:

      + ${rubric} +
      + + % if not read_only: + + % endif + +
      diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 6d9d1a3a30..0f1893ee4f 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -1,6 +1,14 @@ ${module_content} %if edit_link: -
      Edit / QA
      +
      + Edit / + QA +
      % endif
      Staff Debug Info
      @@ -50,77 +58,19 @@ category = ${category | h}
      -## leanModal needs to be included here otherwise this breaks when in a - - diff --git a/lms/templates/static_templates/jobs.html b/lms/templates/static_templates/jobs.html index f2b3b69cab..15fcbfcdca 100644 --- a/lms/templates/static_templates/jobs.html +++ b/lms/templates/static_templates/jobs.html @@ -1,5 +1,4 @@ <%namespace name='static' file='../static_content.html'/> - <%inherit file="../main.html" /> <%block name="title">Jobs @@ -31,32 +30,20 @@
      -
      +
      -

      EdX Content Engineer

      -

      Content Engineers support edX Fellows and edX Course Managers in the overall technical development of course content, assessments, and domain-specific online tools. Tasks include developing graders for rich problems, designing automated tools for import of problems from other formats, as well as creating new ways for students to interact with domain-specific problems in the system.

      -

      A candidate must have:

      -
        -
      • Python or JavaScript development experience
      • -
      • A deep interest in pedagogy and education
      • -
      -

      Knowledge of GWT or Backbone.js a plus.

      If you are interested in this position, please send an email to jobs@edx.org.

      -
      -
      -
      -
      -

      Platform Developer

      -

      Platform Developers build the core learning platform that powers edX, writing both front-end and back-end code. They tackle a wide range of technical challenges, and so the best candidates will have a strong background in one or more of the following areas: machine learning, education, user interaction design, big data, social network analysis, and devops. Specialists are encouraged to apply, but team members often wear many hats. Our ideal candidate would have excellent coding skills, a proven history of delivering projects, and a deep research background.

      -

      If you are interested in this position, please send an email to jobs@edx.org

      -
      +

      We're hiring!

      +

      Are you passionate? Want to help change the world? Good, you've found the right company! We're growing and our team needs the best and brightest in creating the next evolution in interactive online education.

      +

      Want to apply to edX?

      +

      Send your resume and cover letter to jobs@edx.org.

      +

      Note: We'll review each and every resume but please note you may not get a response due to the volume of inquiries.

      -

      Positions

      -
      - EdX Content Engineer - Platform Developer -
      + + + +

      How to Apply

      E-mail your resume, coverletter and any other materials to jobs@edx.org

      Our Location

      diff --git a/lms/templates/video.html b/lms/templates/video.html index 47556095cb..5c041d5c70 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -18,3 +18,9 @@

      Download video here.

      % endif + +% if track: +
      +

      Download subtitles here.

      +
      +% endif diff --git a/lms/urls.py b/lms/urls.py index 8cc81a438b..5c744e7e89 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -10,6 +10,9 @@ if settings.DEBUG: admin.autodiscover() urlpatterns = ('', + # certificate view + + url(r'^update_certificate$', 'certificates.views.update_certificate'), url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware url(r'^dashboard$', 'student.views.dashboard', name="dashboard"), @@ -26,6 +29,11 @@ urlpatterns = ('', url(r'^reject_name_change$', 'student.views.reject_name_change'), url(r'^pending_name_changes$', 'student.views.pending_name_changes'), + url(r'^testcenter/login$', 'student.views.test_center_login'), + + # url(r'^testcenter/login$', 'student.test_center_views.login'), + # url(r'^testcenter/logout$', 'student.test_center_views.logout'), + url(r'^event$', 'track.views.user_track'), url(r'^t/(?P