diff --git a/.gitignore b/.gitignore index 2fd1ca0181..493df5a7fd 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ lms/lib/comment_client/python nosetests.xml cover_html/ .idea/ +.redcar/ chromedriver.log \ No newline at end of file diff --git a/cms/.coveragerc b/cms/.coveragerc index b7ae181e99..4f0dbebe79 100644 --- a/cms/.coveragerc +++ b/cms/.coveragerc @@ -2,7 +2,7 @@ [run] data_file = reports/cms/.coverage source = cms,common/djangoapps -omit = cms/envs/*, cms/manage.py +omit = cms/envs/*, cms/manage.py, common/djangoapps/terrain/*, common/djangoapps/*/migrations/* [report] ignore_errors = True diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature new file mode 100644 index 0000000000..4708a60be1 --- /dev/null +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -0,0 +1,51 @@ +Feature: Advanced (manual) course policy + In order to specify course policy settings for which no custom user interface exists + I want to be able to manually enter JSON key/value pairs + + Scenario: A course author sees only display_name on a newly created course + Given I have opened a new course in Studio + When I select the Advanced Settings + Then I see only the display name + + Scenario: Test if there are no policy settings without existing UI controls + Given I am on the Advanced Course Settings page in Studio + When I delete the display name + Then there are no advanced policy settings + And I reload the page + Then there are no advanced policy settings + + Scenario: Test cancel editing key name + Given I am on the Advanced Course Settings page in Studio + When I edit the name of a policy key + And I press the "Cancel" notification button + Then the policy key name is unchanged + + Scenario: Test editing key name + Given I am on the Advanced Course Settings page in Studio + When I edit the name of a policy key + And I press the "Save" notification button + Then the policy key name is changed + + Scenario: Test cancel editing key value + Given I am on the Advanced Course Settings page in Studio + When I edit the value of a policy key + And I press the "Cancel" notification button + Then the policy key value is unchanged + + Scenario: Test editing key value + Given I am on the Advanced Course Settings page in Studio + When I edit the value of a policy key + And I press the "Save" notification button + Then the policy key value is changed + + Scenario: Add new entries, and they appear alphabetically after save + Given I am on the Advanced Course Settings page in Studio + When I create New Entries + Then they are alphabetized + And I reload the page + Then they are alphabetized + + Scenario: Test how multi-line input appears + Given I am on the Advanced Course Settings page in Studio + When I create a JSON object + Then it is displayed as formatted diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py new file mode 100644 index 0000000000..91daf70718 --- /dev/null +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -0,0 +1,182 @@ +from lettuce import world, step +from common import * +import time + +from nose.tools import assert_equal +from nose.tools import assert_true + +""" +http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html +""" +from selenium.webdriver.common.keys import Keys + + +############### ACTIONS #################### +@step('I select the Advanced Settings$') +def i_select_advanced_settings(step): + expand_icon_css = 'li.nav-course-settings i.icon-expand' + if world.browser.is_element_present_by_css(expand_icon_css): + css_click(expand_icon_css) + link_css = 'li.nav-course-settings-advanced a' + css_click(link_css) + + +@step('I am on the Advanced Course Settings page in Studio$') +def i_am_on_advanced_course_settings(step): + step.given('I have opened a new course in Studio') + step.given('I select the Advanced Settings') + + +# TODO: this is copied from terrain's step.py. Need to figure out how to share that code. +@step('I reload the page$') +def reload_the_page(step): + world.browser.reload() + + +@step(u'I edit the name of a policy key$') +def edit_the_name_of_a_policy_key(step): + policy_key_css = 'input.policy-key' + e = css_find(policy_key_css).first + e.fill('new') + + +@step(u'I press the "([^"]*)" notification button$') +def press_the_notification_button(step, name): + world.browser.click_link_by_text(name) + + +@step(u'I edit the value of a policy key$') +def edit_the_value_of_a_policy_key(step): + """ + It is hard to figure out how to get into the CodeMirror + area, so cheat and do it from the policy key field :) + """ + policy_key_css = 'input.policy-key' + e = css_find(policy_key_css).first + e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X') + + +@step('I delete the display name$') +def delete_the_display_name(step): + delete_entry(0) + click_save() + + +@step('create New Entries$') +def create_new_entries(step): + create_entry("z", "apple") + create_entry("a", "zebra") + click_save() + + +@step('I create a JSON object$') +def create_JSON_object(step): + create_entry("json", '{"key": "value", "key_2": "value_2"}') + click_save() + + +############### RESULTS #################### +@step('I see only the display name$') +def i_see_only_display_name(step): + assert_policy_entries(["display_name"], ['"Robot Super Course"']) + + +@step('there are no advanced policy settings$') +def no_policy_settings(step): + assert_policy_entries([], []) + + +@step('they are alphabetized$') +def they_are_alphabetized(step): + assert_policy_entries(["a", "display_name", "z"], ['"zebra"', '"Robot Super Course"', '"apple"']) + + +@step('it is displayed as formatted$') +def it_is_formatted(step): + assert_policy_entries(["display_name", "json"], ['"Robot Super Course"', '{\n "key": "value",\n "key_2": "value_2"\n}']) + + +@step(u'the policy key name is unchanged$') +def the_policy_key_name_is_unchanged(step): + policy_key_css = 'input.policy-key' + e = css_find(policy_key_css).first + assert_equal(e.value, 'display_name') + + +@step(u'the policy key name is changed$') +def the_policy_key_name_is_changed(step): + policy_key_css = 'input.policy-key' + e = css_find(policy_key_css).first + assert_equal(e.value, 'new') + + +@step(u'the policy key value is unchanged$') +def the_policy_key_value_is_unchanged(step): + policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' + e = css_find(policy_value_css).first + assert_equal(e.value, '"Robot Super Course"') + + +@step(u'the policy key value is changed$') +def the_policy_key_value_is_unchanged(step): + policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' + e = css_find(policy_value_css).first + assert_equal(e.value, '"Robot Super Course X"') + + +############# HELPERS ############### +def create_entry(key, value): + # Scroll down the page so the button is visible + world.scroll_to_bottom() + css_click_at('a.new-advanced-policy-item', 10, 10) + new_key_css = 'div#__new_advanced_key__ input' + new_key_element = css_find(new_key_css).first + new_key_element.fill(key) +# For some reason have to get the instance for each command (get error that it is no longer attached to the DOM) +# Have to do all this because Selenium has a bug that fill does not remove existing text + new_value_css = 'div.CodeMirror textarea' + css_find(new_value_css).last.fill("") + css_find(new_value_css).last._element.send_keys(Keys.DELETE, Keys.DELETE) + css_find(new_value_css).last.fill(value) + + +def delete_entry(index): + """ + Delete the nth entry where index is 0-based + """ + css = '.delete-button' + assert_true(world.browser.is_element_present_by_css(css, 5)) + delete_buttons = css_find(css) + assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index)) + delete_buttons[index].click() + + +def assert_policy_entries(expected_keys, expected_values): + assert_entries('.key input', expected_keys) + assert_entries('.json', expected_values) + + +def assert_entries(css, expected_values): + webElements = css_find(css) + assert_equal(len(expected_values), len(webElements)) +# Sometimes get stale reference if I hold on to the array of elements + for counter in range(len(expected_values)): + assert_equal(expected_values[counter], css_find(css)[counter].value) + + +def click_save(): + css = ".save-button" + + def is_shown(driver): + visible = css_find(css).first.visible + if visible: + # Even when waiting for visible, this fails sporadically. Adding in a small wait. + time.sleep(float(1)) + return visible + wait_for(is_shown) + css_click(css) + + +def fill_last_field(value): + newValue = css_find('#__new_advanced_key__ input').first + newValue.fill(value) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index f868b598a8..61b4fee9f6 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,12 +1,13 @@ from lettuce import world, step -from factories import * -from django.core.management import call_command from lettuce.django import django_url -from django.conf import settings -from django.core.management import call_command from nose.tools import assert_true from nose.tools import assert_equal +from selenium.webdriver.support.ui import WebDriverWait + +from terrain.factories import UserFactory, RegistrationFactory, UserProfileFactory +from terrain.factories import CourseFactory, GroupFactory import xmodule.modulestore.django +from auth.authz import get_user_by_email from logging import getLogger logger = getLogger(__name__) @@ -20,7 +21,8 @@ def i_visit_the_studio_homepage(step): # LETTUCE_SERVER_PORT = 8001 # in your settings.py file. world.browser.visit(django_url('/')) - assert world.browser.is_element_present_by_css('body.no-header', 10) + signin_css = 'a.action-signin' + assert world.browser.is_element_present_by_css(signin_css, 10) @step('I am logged into Studio$') @@ -43,6 +45,13 @@ def i_press_the_category_delete_icon(step, category): assert False, 'Invalid category: %s' % category css_click(css) + +@step('I have opened a new course in Studio$') +def i_have_opened_a_new_course(step): + clear_courses() + log_into_studio() + create_a_course() + ####### HELPER FUNCTIONS ############## @@ -85,13 +94,38 @@ def assert_css_with_text(css, text): def css_click(css): + assert_true(world.browser.is_element_present_by_css(css, 5)) world.browser.find_by_css(css).first.click() +def css_click_at(css, x=10, y=10): + ''' + A method to click at x,y coordinates of the element + rather than in the center of the element + ''' + assert_true(world.browser.is_element_present_by_css(css, 5)) + e = world.browser.find_by_css(css).first + e.action_chains.move_to_element_with_offset(e._element, x, y) + e.action_chains.click() + e.action_chains.perform() + + def css_fill(css, value): world.browser.find_by_css(css).first.fill(value) +def css_find(css): + return world.browser.find_by_css(css) + + +def wait_for(func): + WebDriverWait(world.browser.driver, 10).until(func) + + +def id_find(id): + return world.browser.find_by_id(id) + + def clear_courses(): flush_xmodule_store() @@ -113,7 +147,11 @@ def log_into_studio( create_studio_user(uname=uname, email=email, is_staff=is_staff) world.browser.cookies.delete() world.browser.visit(django_url('/')) - world.browser.is_element_present_by_css('body.no-header', 10) + signin_css = 'a.action-signin' + world.browser.is_element_present_by_css(signin_css, 10) + + # click the signin button + css_click(signin_css) login_form = world.browser.find_by_css('form#login_form') login_form.find_by_name('email').fill(email) @@ -124,19 +162,31 @@ def log_into_studio( def create_a_course(): - css_click('a.new-course-button') - fill_in_course_info() - css_click('input.new-course-save') - assert_true(world.browser.is_element_present_by_css('a#courseware-tab', 5)) + c = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + + # Add the user to the instructor group of the course + # so they will have the permissions to see it in studio + g = GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course') + u = get_user_by_email('robot+studio@edx.org') + u.groups.add(g) + u.save() + world.browser.reload() + + course_link_css = 'span.class-name' + css_click(course_link_css) + course_title_css = 'span.course-title' + assert_true(world.browser.is_element_present_by_css(course_title_css, 5)) def add_section(name='My Section'): link_css = 'a.new-courseware-section-button' css_click(link_css) - name_css = '.new-section-name' - save_css = '.new-section-name-save' + name_css = 'input.new-section-name' + save_css = 'input.new-section-name-save' css_fill(name_css, name) css_click(save_css) + span_css = 'span.section-name-span' + assert_true(world.browser.is_element_present_by_css(span_css, 5)) def add_subsection(name='Subsection One'): diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index d2d038a928..e394165f08 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -34,8 +34,8 @@ def i_click_the_course_link_in_my_courses(step): @step('the Courseware page has loaded in Studio$') def courseware_page_has_loaded_in_studio(step): - courseware_css = 'a#courseware-tab' - assert world.browser.is_element_present_by_css(courseware_css) + course_title_css = 'span.course-title' + assert world.browser.is_element_present_by_css(course_title_css) @step('I see the course listed in My Courses$') @@ -59,4 +59,4 @@ def i_am_on_tab(step, tab_name): @step('I see a link for adding a new section$') def i_see_new_section_link(step): link_css = 'a.new-courseware-section-button' - assert_css_with_text(link_css, 'New Section') + assert_css_with_text(link_css, '+ New Section') diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 3bcaeab6c4..ca67c477fb 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -4,13 +4,6 @@ from common import * ############### ACTIONS #################### -@step('I have opened a new course in Studio$') -def i_have_opened_a_new_course(step): - clear_courses() - log_into_studio() - create_a_course() - - @step('I click the new section link$') def i_click_new_section_link(step): link_css = 'a.new-courseware-section-button' @@ -46,6 +39,7 @@ def i_save_a_new_section_release_date(step): css_fill(time_css, '12:00am') css_click('a.save-button') + ############ ASSERTIONS ################### diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature index 8a6f93d33b..03a1c9524a 100644 --- a/cms/djangoapps/contentstore/features/signup.feature +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -5,8 +5,8 @@ Feature: Sign in Scenario: Sign up from the homepage Given I visit the Studio homepage - When I click the link with the text "Sign up" + When I click the link with the text "Sign Up" And I fill in the registration form - And I press the "Create My Account" button on the registration form + And I press the Create My Account button on the registration form Then I should see be on the studio home page - And I should see the message "please click on the activation link in your email." \ No newline at end of file + And I should see the message "please click on the activation link in your email." diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index e105b674f7..a786225ead 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -11,10 +11,11 @@ def i_fill_in_the_registration_form(step): register_form.find_by_name('terms_of_service').check() -@step('I press the "([^"]*)" button on the registration form$') -def i_press_the_button_on_the_registration_form(step, button): +@step('I press the Create My Account button on the registration form$') +def i_press_the_button_on_the_registration_form(step): register_form = world.browser.find_by_css('form#register_form') - register_form.find_by_value(button).click() + submit_css = 'button#submit' + register_form.find_by_css(submit_css).click() @step('I should see be on the studio home page$') diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 72ae3821cc..66e6551019 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,15 +1,16 @@ import json import shutil from django.test.client import Client -from override_settings import override_settings +from django.test.utils import override_settings from django.conf import settings from django.core.urlresolvers import reverse from path import path -from tempfile import mkdtemp +from tempdir import mkdtemp_clean import json from fs.osfs import OSFS import copy from mock import Mock +from json import dumps, loads from student.models import Registration from django.contrib.auth.models import User @@ -26,10 +27,12 @@ from xmodule.contentstore.django import contentstore from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.templates import update_templates from xmodule.capa_module import CapaDescriptor from xmodule.course_module import CourseDescriptor from xmodule.seq_module import SequenceDescriptor +from xmodule.modulestore.exceptions import ItemNotFoundError TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') @@ -191,7 +194,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): import_from_xml(ms, 'common/test/data/', ['full']) location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - root_dir = path(mkdtemp()) + root_dir = path(mkdtemp_clean()) print 'Exporting to tempdir = {0}'.format(root_dir) @@ -207,6 +210,24 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # check for custom_tags self.verify_content_existence(ms, root_dir, location, 'custom_tags', 'custom_tag_template') + # check for graiding_policy.json + fs = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') + self.assertTrue(fs.exists('grading_policy.json')) + + course = ms.get_item(location) + # compare what's on disk compared to what we have in our course + with fs.open('grading_policy.json','r') as grading_policy: + on_disk = loads(grading_policy.read()) + self.assertEqual(on_disk, course.definition['data']['grading_policy']) + + #check for policy.json + self.assertTrue(fs.exists('policy.json')) + + # compare what's on disk to what we have in the course module + with fs.open('policy.json','r') as course_policy: + on_disk = loads(course_policy.read()) + self.assertIn('course/6.002_Spring_2012', on_disk) + self.assertEqual(on_disk['course/6.002_Spring_2012'], course.metadata) # remove old course delete_course(ms, cs, location) @@ -243,6 +264,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') + class ContentStoreTest(ModuleStoreTestCase): """ Tests for the CMS ContentStore application. @@ -321,7 +343,7 @@ class ContentStoreTest(ModuleStoreTestCase): # Create a course so there is something to view resp = self.client.get(reverse('index')) self.assertContains(resp, - '

My Courses

', + '

My Courses

', status_code=200, html=True) @@ -357,7 +379,7 @@ class ContentStoreTest(ModuleStoreTestCase): resp = self.client.get(reverse('course_index', kwargs=data)) self.assertContains(resp, - 'Robot Super Course', + '
', status_code=200, html=True) @@ -380,11 +402,11 @@ class ContentStoreTest(ModuleStoreTestCase): def test_capa_module(self): """Test that a problem treats markdown specially.""" - CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + course = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') problem_data = { 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', - 'template': 'i4x://edx/templates/problem/Empty' + 'template': 'i4x://edx/templates/problem/Blank_Common_Problem' } resp = self.client.post(reverse('clone_item'), problem_data) @@ -399,3 +421,90 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertIn('markdown', context, "markdown is missing from context") self.assertIn('markdown', problem.metadata, "markdown is missing from metadata") self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields") + + def test_import_metadata_with_attempts_empty_string(self): + import_from_xml(modulestore(), 'common/test/data/', ['simple']) + ms = modulestore('direct') + did_load_item = False + try: + ms.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) + did_load_item = True + except ItemNotFoundError: + pass + + # make sure we found the item (e.g. it didn't error while loading) + self.assertTrue(did_load_item) + + def test_metadata_inheritance(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + ms = modulestore('direct') + course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + verticals = ms.get_items(['i4x', 'edX', 'full', 'vertical', None, None]) + + # let's assert on the metadata_inheritance on an existing vertical + for vertical in verticals: + self.assertIn('xqa_key', vertical.metadata) + self.assertEqual(course.metadata['xqa_key'], vertical.metadata['xqa_key']) + + self.assertGreater(len(verticals), 0) + + new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component') + source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') + + # crate a new module and add it as a child to a vertical + ms.clone_item(source_template_location, new_component_location) + parent = verticals[0] + ms.update_children(parent.location, parent.definition.get('children', []) + [new_component_location.url()]) + + # flush the cache + ms.get_cached_metadata_inheritance_tree(new_component_location, -1) + new_module = ms.get_item(new_component_location) + + # check for grace period definition which should be defined at the course level + self.assertIn('graceperiod', new_module.metadata) + + self.assertEqual(course.metadata['graceperiod'], new_module.metadata['graceperiod']) + + # + # now let's define an override at the leaf node level + # + new_module.metadata['graceperiod'] = '1 day' + ms.update_metadata(new_module.location, new_module.metadata) + + # flush the cache and refetch + ms.get_cached_metadata_inheritance_tree(new_component_location, -1) + new_module = ms.get_item(new_component_location) + + self.assertIn('graceperiod', new_module.metadata) + self.assertEqual('1 day', new_module.metadata['graceperiod']) + + +class TemplateTestCase(ModuleStoreTestCase): + + def test_template_cleanup(self): + ms = modulestore('direct') + + # insert a bogus template in the store + bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus') + source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') + + ms.clone_item(source_template_location, bogus_template_location) + + verify_create = ms.get_item(bogus_template_location) + self.assertIsNotNone(verify_create) + + # now run cleanup + update_templates() + + # now try to find dangling template, it should not be in DB any longer + asserted = False + try: + verify_create = ms.get_item(bogus_template_location) + except ItemNotFoundError: + asserted = True + + self.assertTrue(asserted) + + diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 84e79b9670..5560d2e39b 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1,7 +1,5 @@ import datetime -import time import json -import calendar import copy from util import converters from util.converters import jsdate_to_time @@ -11,7 +9,6 @@ from django.test.client import Client from django.core.urlresolvers import reverse from django.utils.timezone import UTC -import xmodule from xmodule.modulestore import Location from cms.djangoapps.models.settings.course_details import (CourseDetails, CourseSettingsEncoder) @@ -22,6 +19,10 @@ from django.test import TestCase from utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.django import modulestore + # YYYY-MM-DDThh:mm:ss.s+/-HH:MM class ConvertersTestCase(TestCase): @@ -143,10 +144,6 @@ class CourseDetailsViewTest(CourseTestCase): def test_update_and_fetch(self): details = CourseDetails.fetch(self.course_location) - resp = self.client.get(reverse('course_settings', kwargs={'org': self.course_location.org, 'course': self.course_location.course, - 'name': self.course_location.name})) - self.assertContains(resp, '
  • Course Details
  • ', status_code=200, html=True) - # resp s/b json from here on url = reverse('course_settings', kwargs={'org': self.course_location.org, 'course': self.course_location.course, 'name': self.course_location.name, 'section': 'details'}) @@ -249,7 +246,7 @@ class CourseGradingTest(CourseTestCase): altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D") - test_grader.grace_period = {'hours' : '4'} + test_grader.grace_period = {'hours' : 4, 'minutes' : 5, 'seconds': 0} altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period") @@ -265,3 +262,64 @@ class CourseGradingTest(CourseTestCase): test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1 altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2") + +class CourseMetadataEditingTest(CourseTestCase): + def setUp(self): + CourseTestCase.setUp(self) + # add in the full class too + import_from_xml(modulestore(), 'common/test/data/', ['full']) + self.fullcourse_location = Location(['i4x','edX','full','course','6.002_Spring_2012', None]) + + + def test_fetch_initial_fields(self): + test_model = CourseMetadata.fetch(self.course_location) + self.assertIn('display_name', test_model, 'Missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") + + test_model = CourseMetadata.fetch(self.fullcourse_location) + self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') + self.assertIn('display_name', test_model, 'full missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Testing', "not expected value") + self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') + self.assertIn('showanswer', test_model, 'showanswer field ') + self.assertIn('xqa_key', test_model, 'xqa_key field ') + + def test_update_from_json(self): + test_model = CourseMetadata.update_from_json(self.course_location, + { "a" : 1, + "b_a_c_h" : { "c" : "test" }, + "test_text" : "a text string"}) + self.update_check(test_model) + # try fresh fetch to ensure persistence + test_model = CourseMetadata.fetch(self.course_location) + self.update_check(test_model) + # now change some of the existing metadata + test_model = CourseMetadata.update_from_json(self.course_location, + { "a" : 2, + "display_name" : "jolly roger"}) + self.assertIn('display_name', test_model, 'Missing editable metadata field') + self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value") + self.assertIn('a', test_model, 'Missing revised a metadata field') + self.assertEqual(test_model['a'], 2, "a not expected value") + + def update_check(self, test_model): + self.assertIn('display_name', test_model, 'Missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") + self.assertIn('a', test_model, 'Missing new a metadata field') + self.assertEqual(test_model['a'], 1, "a not expected value") + self.assertIn('b_a_c_h', test_model, 'Missing b_a_c_h metadata field') + self.assertDictEqual(test_model['b_a_c_h'], { "c" : "test" }, "b_a_c_h not expected value") + self.assertIn('test_text', test_model, 'Missing test_text metadata field') + self.assertEqual(test_model['test_text'], "a text string", "test_text not expected value") + + + def test_delete_key(self): + test_model = CourseMetadata.delete_key(self.fullcourse_location, { 'deleteKeys' : ['doesnt_exist', 'showanswer', 'xqa_key']}) + # ensure no harm + self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') + self.assertIn('display_name', test_model, 'full missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Testing', "not expected value") + self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') + # check for deletion effectiveness + self.assertNotIn('showanswer', test_model, 'showanswer field still in') + self.assertNotIn('xqa_key', test_model, 'xqa_key field still in') \ No newline at end of file diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 9af5b09276..c4a46459e2 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -1,11 +1,9 @@ import json import shutil from django.test.client import Client -from override_settings import override_settings from django.conf import settings from django.core.urlresolvers import reverse from path import path -from tempfile import mkdtemp import json from fs.osfs import OSFS import copy @@ -86,7 +84,6 @@ class ContentStoreTestCase(ModuleStoreTestCase): # Now make sure that the user is now actually activated self.assertTrue(user(email).is_active) - class AuthTestCase(ContentStoreTestCase): """Check that various permissions-related things work""" diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 4e3510463f..b6b8cd5023 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -1,8 +1,7 @@ import json import copy -from time import time +from uuid import uuid4 from django.test import TestCase -from override_settings import override_settings from django.conf import settings from student.models import Registration @@ -21,13 +20,12 @@ class ModuleStoreTestCase(TestCase): def _pre_setup(self): super(ModuleStoreTestCase, self)._pre_setup() - # Use the current seconds since epoch to differentiate + # Use a uuid to differentiate # the mongo collections on jenkins. - sec_since_epoch = '%s' % int(time() * 100) self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE) self.test_MODULESTORE = self.orig_MODULESTORE - self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % sec_since_epoch - self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % sec_since_epoch + self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex + self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex settings.MODULESTORE = self.test_MODULESTORE # Flush and initialize the module store diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index b14dd8b353..cf0c281dd4 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -75,12 +75,15 @@ def get_course_for_item(location): return courses[0] -def get_lms_link_for_item(location, preview=False): +def get_lms_link_for_item(location, preview=False, course_id=None): + if course_id is None: + course_id = get_course_id(location) + if settings.LMS_BASE is not None: lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format( preview='preview.' if preview else '', lms_base=settings.LMS_BASE, - course_id=get_course_id(location), + course_id= course_id, location=Location(location) ) else: diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 87a2943773..b55dc13e58 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -58,7 +58,8 @@ from cms.djangoapps.models.settings.course_details import CourseDetails,\ CourseSettingsEncoder from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.contentstore.utils import get_modulestore -from lxml import etree +from django.shortcuts import redirect +from cms.djangoapps.models.settings.course_metadata import CourseMetadata # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' @@ -81,6 +82,11 @@ def signup(request): csrf_token = csrf(request)['csrf_token'] return render_to_response('signup.html', {'csrf': csrf_token}) +def old_login_redirect(request): + ''' + Redirect to the active login url. + ''' + return redirect('login', permanent=True) @ssl_login_shortcut @ensure_csrf_cookie @@ -94,6 +100,11 @@ def login_page(request): 'forgot_password_link': "//{base}/#forgot-password-modal".format(base=settings.LMS_BASE), }) +def howitworks(request): + if request.user.is_authenticated(): + return index(request) + else: + return render_to_response('howitworks.html', {}) # ==== Views for any logged-in user ================================== @@ -103,7 +114,7 @@ def index(request): """ List all courses available to the logged in user """ - courses = modulestore().get_items(['i4x', None, None, 'course', None]) + courses = modulestore('direct').get_items(['i4x', None, None, 'course', None]) # filter out courses that we don't have access too def course_filter(course): @@ -120,7 +131,8 @@ def index(request): reverse('course_index', args=[ course.location.org, course.location.course, - course.location.name])) + course.location.name]), + get_lms_link_for_item(course.location, course_id=course.location.course_id)) for course in courses], 'user': request.user, 'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff @@ -161,6 +173,8 @@ def course_index(request, org, course, name): if not has_access(request.user, location): raise PermissionDenied() + lms_link = get_lms_link_for_item(location) + upload_asset_callback_url = reverse('upload_asset', kwargs={ 'org': org, 'course': course, @@ -173,6 +187,7 @@ def course_index(request, org, course, name): return render_to_response('overview.html', { 'active_tab': 'courseware', 'context_course': course, + 'lms_link': lms_link, 'sections': sections, 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), 'parent_location': course.location, @@ -273,7 +288,7 @@ def edit_unit(request, location): template.display_name, template.location.url(), 'markdown' in template.metadata, - template.location.name == 'Empty' + 'empty' in template.metadata )) components = [ @@ -350,7 +365,6 @@ def preview_component(request, location): 'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(), }) - @expect_json @login_required @ensure_csrf_cookie @@ -667,7 +681,6 @@ def create_draft(request): return HttpResponse() - @login_required @expect_json def publish_draft(request): @@ -697,7 +710,6 @@ def unpublish_unit(request): return HttpResponse() - @login_required @expect_json def clone_item(request): @@ -730,8 +742,6 @@ def clone_item(request): #@login_required #@ensure_csrf_cookie - - def upload_asset(request, org, course, coursename): ''' cdodge: this method allows for POST uploading of files into the course asset library, which will @@ -796,8 +806,6 @@ def upload_asset(request, org, course, coursename): ''' This view will return all CMS users who are editors for the specified course ''' - - @login_required @ensure_csrf_cookie def manage_users(request, location): @@ -819,7 +827,7 @@ def manage_users(request, location): }) -def create_json_response(errmsg=None): +def create_json_response(errmsg = None): if errmsg is not None: resp = HttpResponse(json.dumps({'Status': 'Failed', 'ErrMsg': errmsg})) else: @@ -831,8 +839,6 @@ def create_json_response(errmsg=None): This POST-back view will add a user - specified by email - to the list of editors for the specified course ''' - - @expect_json @login_required @ensure_csrf_cookie @@ -865,8 +871,6 @@ def add_user(request, location): This POST-back view will remove a user - specified by email - from the list of editors for the specified course ''' - - @expect_json @login_required @ensure_csrf_cookie @@ -894,7 +898,6 @@ def remove_user(request, location): def landing(request, org, course, coursename): return render_to_response('temp-course-landing.html', {}) - @login_required @ensure_csrf_cookie def static_pages(request, org, course, coursename): @@ -998,7 +1001,6 @@ def edit_tabs(request, org, course, coursename): 'components': components }) - def not_found(request): return render_to_response('error.html', {'error': '404'}) @@ -1034,7 +1036,6 @@ def course_info(request, org, course, name, provided_id=None): 'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() }) - @expect_json @login_required @ensure_csrf_cookie @@ -1105,7 +1106,6 @@ def module_info(request, module_location): else: return HttpResponseBadRequest() - @login_required @ensure_csrf_cookie def get_course_settings(request, org, course, name): @@ -1124,11 +1124,56 @@ def get_course_settings(request, org, course, name): course_details = CourseDetails.fetch(location) return render_to_response('settings.html', { - 'active_tab': 'settings', 'context_course': course_module, + 'course_location' : location, + 'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder) + }) + +@login_required +@ensure_csrf_cookie +def course_config_graders_page(request, org, course, name): + """ + Send models and views as well as html for editing the course settings to the client. + + org, course, name: Attributes of the Location for the item to edit + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + course_module = modulestore().get_item(location) + course_details = CourseGradingModel.fetch(location) + + return render_to_response('settings_graders.html', { + 'context_course': course_module, + 'course_location' : location, 'course_details': json.dumps(course_details, cls=CourseSettingsEncoder) }) +@login_required +@ensure_csrf_cookie +def course_config_advanced_page(request, org, course, name): + """ + Send models and views as well as html for editing the advanced course settings to the client. + + org, course, name: Attributes of the Location for the item to edit + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + course_module = modulestore().get_item(location) + + return render_to_response('settings_advanced.html', { + 'context_course': course_module, + 'course_location' : location, + 'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST), + 'advanced_dict' : json.dumps(CourseMetadata.fetch(location)), + }) @expect_json @login_required @@ -1161,7 +1206,6 @@ def course_settings_updates(request, org, course, name, section): return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder), mimetype="application/json") - @expect_json @login_required @ensure_csrf_cookie @@ -1196,6 +1240,37 @@ def course_grader_updates(request, org, course, name, grader_index=None): return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)), mimetype="application/json") + +## NB: expect_json failed on ["key", "key2"] and json payload +@login_required +@ensure_csrf_cookie +def course_advanced_updates(request, org, course, name): + """ + restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh, + the payload is either a key or a list of keys to delete. + + org, course: Attributes of the Location for the item to edit + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! + if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: + real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] + else: + real_method = request.method + + if real_method == 'GET': + return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json") + elif real_method == 'DELETE': + return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json") + elif real_method == 'POST' or real_method == 'PUT': + # NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key + return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json") + @login_required @ensure_csrf_cookie @@ -1256,7 +1331,6 @@ def asset_index(request, org, course, name): def edge(request): return render_to_response('university_profiles/edge.html', {}) - @login_required @expect_json def create_new_course(request): @@ -1312,7 +1386,6 @@ def create_new_course(request): return HttpResponse(json.dumps({'id': new_course.location.url()})) - def initialize_course_tabs(course): # 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 @@ -1330,7 +1403,6 @@ def initialize_course_tabs(course): modulestore('direct').update_metadata(course.location.url(), course.own_metadata) - @ensure_csrf_cookie @login_required def import_course(request, org, course, name): @@ -1408,7 +1480,6 @@ def import_course(request, org, course, name): course_module.location.name]) }) - @ensure_csrf_cookie @login_required def generate_export_course(request, org, course, name): @@ -1460,7 +1531,6 @@ def export_course(request, org, course, name): 'successful_import_redirect_url': '' }) - def event(request): ''' A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index f4c6fd3d7c..3d0b8f78af 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -155,7 +155,8 @@ class CourseGradingModel(object): if 'grace_period' in graceperiodjson: graceperiodjson = graceperiodjson['grace_period'] - grace_rep = " ".join(["%s %s" % (value, key) for (key, value) in graceperiodjson.iteritems()]) + # lms requires these to be in a fixed order + grace_rep = "{0[hours]:d} hours {0[minutes]:d} minutes {0[seconds]:d} seconds".format(graceperiodjson) descriptor = get_modulestore(course_location).get_item(course_location) descriptor.metadata['graceperiod'] = grace_rep @@ -234,10 +235,10 @@ class CourseGradingModel(object): @staticmethod def convert_set_grace_period(descriptor): - # 5 hours 59 minutes 59 seconds => converted to iso format + # 5 hours 59 minutes 59 seconds => { hours: 5, minutes : 59, seconds : 59} rawgrace = descriptor.metadata.get('graceperiod', None) if rawgrace: - parsedgrace = {str(key): val for (val, key) in re.findall('\s*(\d+)\s*(\w+)', rawgrace)} + parsedgrace = {str(key): int(val) for (val, key) in re.findall('\s*(\d+)\s*(\w+)', rawgrace)} return parsedgrace else: return None diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py new file mode 100644 index 0000000000..d088d75665 --- /dev/null +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -0,0 +1,70 @@ +from xmodule.modulestore import Location +from contentstore.utils import get_modulestore +from xmodule.x_module import XModuleDescriptor + + +class CourseMetadata(object): + ''' + For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones. + The objects have no predefined attrs but instead are obj encodings of the editable metadata. + ''' + # __new_advanced_key__ is used by client not server; so, could argue against it being here + FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__'] + + @classmethod + def fetch(cls, course_location): + """ + Fetch the key:value editable course details for the given course from persistence and return a CourseMetadata model. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + course = {} + + descriptor = get_modulestore(course_location).get_item(course_location) + + for k, v in descriptor.metadata.iteritems(): + if k not in cls.FILTERED_LIST: + course[k] = v + + return course + + @classmethod + def update_from_json(cls, course_location, jsondict): + """ + Decode the json into CourseMetadata and save any changed attrs to the db. + + Ensures none of the fields are in the blacklist. + """ + descriptor = get_modulestore(course_location).get_item(course_location) + + dirty = False + + for k, v in jsondict.iteritems(): + # should it be an error if one of the filtered list items is in the payload? + if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v): + dirty = True + descriptor.metadata[k] = v + + if dirty: + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + + # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm + # it persisted correctly + return cls.fetch(course_location) + + @classmethod + def delete_key(cls, course_location, payload): + ''' + Remove the given metadata key(s) from the course. payload can be a single key or [key..] + ''' + descriptor = get_modulestore(course_location).get_item(course_location) + + for key in payload['deleteKeys']: + if key in descriptor.metadata: + del descriptor.metadata[key] + + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + + return cls.fetch(course_location) + \ No newline at end of file diff --git a/cms/envs/common.py b/cms/envs/common.py index 30aac6ea01..50f237c374 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -20,7 +20,6 @@ Longer TODO: """ import sys -import tempfile import os.path import os import lms.envs.common @@ -59,7 +58,8 @@ sys.path.append(COMMON_ROOT / 'lib') ############################# WEB CONFIGURATION ############################# # This is where we stick our compiled template files. -MAKO_MODULE_DIR = tempfile.mkdtemp('mako') +from tempdir import mkdtemp_clean +MAKO_MODULE_DIR = mkdtemp_clean('mako') MAKO_TEMPLATES = {} MAKO_TEMPLATES['main'] = [ PROJECT_ROOT / 'templates', @@ -74,8 +74,8 @@ TEMPLATE_DIRS = MAKO_TEMPLATES['main'] MITX_ROOT_URL = '' -LOGIN_REDIRECT_URL = MITX_ROOT_URL + '/login' -LOGIN_URL = MITX_ROOT_URL + '/login' +LOGIN_REDIRECT_URL = MITX_ROOT_URL + '/signin' +LOGIN_URL = MITX_ROOT_URL + '/signin' TEMPLATE_CONTEXT_PROCESSORS = ( diff --git a/cms/static/client_templates/advanced_entry.html b/cms/static/client_templates/advanced_entry.html new file mode 100644 index 0000000000..0312fdd344 --- /dev/null +++ b/cms/static/client_templates/advanced_entry.html @@ -0,0 +1,16 @@ +
  • +
    + + + Keys are case sensitive and cannot contain spaces or start with a number +
    + +
    + + +
    + +
    + Delete +
    +
  • \ No newline at end of file diff --git a/cms/static/client_templates/course_grade_policy.html b/cms/static/client_templates/course_grade_policy.html index c9a21280dd..db129614f6 100644 --- a/cms/static/client_templates/course_grade_policy.html +++ b/cms/static/client_templates/course_grade_policy.html @@ -1,69 +1,37 @@ -
  • -
    - +
  • +
    + + + e.g. Homework, Midterm Exams +
    -
    -
    - - e.g. Homework, Labs, Midterm Exams, Final Exam -
    -
    - - -
    - - -
    -
    - - e.g. HW, Midterm, Final -
    -
    -
    - -
    - - -
    -
    - - e.g. 25% -
    -
    -
    - -
    - - -
    -
    - - total exercises assigned -
    -
    -
    - -
    - - -
    -
    - - total exercises that won't be graded -
    -
    -
    - Delete +
    + + + e.g. HW, Midterm +
    + +
    + + + e.g. 25% +
    + +
    + + + total exercises assigned +
    + +
    + + + total exercises that won't be graded +
    + +
    + Delete +
  • diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee index 5a826c1794..9fbe4e5789 100644 --- a/cms/static/coffee/src/views/tabs.coffee +++ b/cms/static/coffee/src/views/tabs.coffee @@ -1,6 +1,4 @@ class CMS.Views.TabsEdit extends Backbone.View - events: - 'click .new-tab': 'addNewTab' initialize: => @$('.component').each((idx, element) => @@ -13,6 +11,7 @@ class CMS.Views.TabsEdit extends Backbone.View ) ) + @options.mast.find('.new-tab').on('click', @addNewTab) @$('.components').sortable( handle: '.drag-handle' update: @tabMoved diff --git a/cms/static/img/hiw-feature1.png b/cms/static/img/hiw-feature1.png new file mode 100644 index 0000000000..3cfd48d066 Binary files /dev/null and b/cms/static/img/hiw-feature1.png differ diff --git a/cms/static/img/hiw-feature2.png b/cms/static/img/hiw-feature2.png new file mode 100644 index 0000000000..9442325dd5 Binary files /dev/null and b/cms/static/img/hiw-feature2.png differ diff --git a/cms/static/img/hiw-feature3.png b/cms/static/img/hiw-feature3.png new file mode 100644 index 0000000000..fa6b81ae89 Binary files /dev/null and b/cms/static/img/hiw-feature3.png differ diff --git a/cms/static/img/html-icon.png b/cms/static/img/html-icon.png index e739f2fc11..8f576178b2 100644 Binary files a/cms/static/img/html-icon.png and b/cms/static/img/html-icon.png differ diff --git a/cms/static/img/large-discussion-icon.png b/cms/static/img/large-discussion-icon.png index 2f0bfea98f..cebf332769 100644 Binary files a/cms/static/img/large-discussion-icon.png and b/cms/static/img/large-discussion-icon.png differ diff --git a/cms/static/img/large-freeform-icon.png b/cms/static/img/large-freeform-icon.png index b1d195a7ca..0d5e454f58 100644 Binary files a/cms/static/img/large-freeform-icon.png and b/cms/static/img/large-freeform-icon.png differ diff --git a/cms/static/img/large-problem-icon.png b/cms/static/img/large-problem-icon.png index b962d42b14..a30ab8eac8 100644 Binary files a/cms/static/img/large-problem-icon.png and b/cms/static/img/large-problem-icon.png differ diff --git a/cms/static/img/large-video-icon.png b/cms/static/img/large-video-icon.png index 392851324c..f1ab048b4c 100644 Binary files a/cms/static/img/large-video-icon.png and b/cms/static/img/large-video-icon.png differ diff --git a/cms/static/img/logo-edx-studio-white.png b/cms/static/img/logo-edx-studio-white.png new file mode 100644 index 0000000000..3e3ee63622 Binary files /dev/null and b/cms/static/img/logo-edx-studio-white.png differ diff --git a/cms/static/img/logo-edx-studio.png b/cms/static/img/logo-edx-studio.png new file mode 100644 index 0000000000..006194a195 Binary files /dev/null and b/cms/static/img/logo-edx-studio.png differ diff --git a/cms/static/img/pl-1x1-000.png b/cms/static/img/pl-1x1-000.png new file mode 100644 index 0000000000..b94b7a9746 Binary files /dev/null and b/cms/static/img/pl-1x1-000.png differ diff --git a/cms/static/img/pl-1x1-fff.png b/cms/static/img/pl-1x1-fff.png new file mode 100644 index 0000000000..7081c75d36 Binary files /dev/null and b/cms/static/img/pl-1x1-fff.png differ diff --git a/cms/static/img/preview-lms-staticpages.png b/cms/static/img/preview-lms-staticpages.png new file mode 100644 index 0000000000..05a62f7c7f Binary files /dev/null and b/cms/static/img/preview-lms-staticpages.png differ diff --git a/cms/static/img/thumb-hiw-feature1.png b/cms/static/img/thumb-hiw-feature1.png new file mode 100644 index 0000000000..b2dc0c00ee Binary files /dev/null and b/cms/static/img/thumb-hiw-feature1.png differ diff --git a/cms/static/img/thumb-hiw-feature2.png b/cms/static/img/thumb-hiw-feature2.png new file mode 100644 index 0000000000..e96bcad1aa Binary files /dev/null and b/cms/static/img/thumb-hiw-feature2.png differ diff --git a/cms/static/img/thumb-hiw-feature3.png b/cms/static/img/thumb-hiw-feature3.png new file mode 100644 index 0000000000..f694fca516 Binary files /dev/null and b/cms/static/img/thumb-hiw-feature3.png differ diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 7e55d2b8d8..d8b32cb0e8 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -5,7 +5,7 @@ var $newComponentItem; var $changedInput; var $spinner; -$(document).ready(function() { +$(document).ready(function () { $body = $('body'); $modal = $('.history-modal'); $modalCover = $(' diff --git a/cms/templates/activation_complete.html b/cms/templates/activation_complete.html index 5d9437ccb3..1e195a632c 100644 --- a/cms/templates/activation_complete.html +++ b/cms/templates/activation_complete.html @@ -5,7 +5,7 @@

    Activation Complete!

    -

    Thanks for activating your account. Log in here.

    +

    Thanks for activating your account. Log in here.

    diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 01766e2dac..5ace98df56 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> -<%block name="bodyclass">assets -<%block name="title">Courseware Assets +<%block name="bodyclass">is-signedin course uploads +<%block name="title">Uploads & Files <%namespace name='static' file='static_content.html'/> @@ -33,12 +33,27 @@ +
    +
    +
    + Course Content +

    Files & Uploads

    +
    + + +
    +
    +
    diff --git a/cms/templates/base.html b/cms/templates/base.html index 84f10fc2d1..498897bd11 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -5,23 +5,29 @@ + + <%block name="title"></%block> | + % if context_course: + <% ctx_loc = context_course.location %> + ${context_course.display_name} | + % endif + edX Studio + + + + <%static:css group='base-style'/> - + - <%block name="title"></%block> - - - - <%block name="header_extras"> - <%include file="widgets/header.html" args="active_tab=active_tab"/> + <%include file="widgets/header.html" /> <%include file="courseware_vendor_js.html"/> @@ -47,9 +53,9 @@ <%block name="content"> + <%include file="widgets/footer.html" /> <%block name="jsextra"> - diff --git a/cms/templates/course_index.html b/cms/templates/course_index.html index e490ad7817..5c8772c1ed 100644 --- a/cms/templates/course_index.html +++ b/cms/templates/course_index.html @@ -1,5 +1,5 @@ <%inherit file="base.html" /> -<%block name="title">Course Manager + <%include file="widgets/header.html"/> <%block name="content"> diff --git a/cms/templates/course_info.html b/cms/templates/course_info.html index 83d829efa0..a68a0da76a 100644 --- a/cms/templates/course_info.html +++ b/cms/templates/course_info.html @@ -2,8 +2,9 @@ <%namespace name='static' file='static_content.html'/> -<%block name="title">Course Info -<%block name="bodyclass">course-info +<%block name="title">Updates +<%block name="bodyclass">is-signedin course course-info updates + <%block name="jsextra"> @@ -41,16 +42,38 @@ <%block name="content"> +
    +
    +
    + Course Content +

    Course Updates

    +
    + + +
    +
    + +
    +
    +
    +

    Course updates are announcements or notifications you want to share with your class. Other course authors have used them for important exam/date reminders, change in schedules, and to call out any important steps students need to be aware of.

    +
    +
    +
    +
    -

    Course Info

    diff --git a/cms/templates/edit-static-page.html b/cms/templates/edit-static-page.html index 02fe2308fa..f1b2374b46 100644 --- a/cms/templates/edit-static-page.html +++ b/cms/templates/edit-static-page.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> -<%block name="title">Edit Static Page -<%block name="bodyclass">edit-static-page +<%block name="title">Editing Static Page +<%block name="bodyclass">is-signedin course pages edit-static-page <%block name="content">
    diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html index c6ffb14124..1a44de60c1 100644 --- a/cms/templates/edit-tabs.html +++ b/cms/templates/edit-tabs.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> -<%block name="title">Tabs -<%block name="bodyclass">static-pages +<%block name="title">Static Pages +<%block name="bodyclass">is-signedin course pages static-pages <%block name="jsextra"> <%block name="content"> +
    +
    +
    + Course Content +

    Static Pages

    +
    + + +
    +
    + +
    +
    + +
    +
    +
    -
    -

    Here you can add and manage additional pages for your course

    -

    These pages will be added to the primary navigation menu alongside Courseware, Course Info, Discussion, etc.

    -
    - -
      @@ -43,4 +67,17 @@
    + +
    +

    How Static Pages are Used in Your Course

    +
    + Preview of how Static Pages are used in your course +
    These pages will be presented in your course's main navigation alongside Courseware, Course Info, Discussion, etc.
    +
    + + + + close modal + +
    \ No newline at end of file diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index d81f577940..00780eab3b 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -7,8 +7,9 @@ %> <%! from django.core.urlresolvers import reverse %> -<%block name="bodyclass">subsection <%block name="title">CMS Subsection +<%block name="bodyclass">is-signedin course subsection + <%namespace name="units" file="widgets/units.html" /> <%namespace name='static' file='static_content.html'/> @@ -97,6 +98,7 @@
    +
    <%block name="jsextra"> diff --git a/cms/templates/export.html b/cms/templates/export.html index fcdd26458a..27045d82ce 100644 --- a/cms/templates/export.html +++ b/cms/templates/export.html @@ -2,10 +2,19 @@ <%namespace name='static' file='static_content.html'/> <%! from django.core.urlresolvers import reverse %> -<%block name="title">Export -<%block name="bodyclass">export +<%block name="title">Export Course +<%block name="bodyclass">is-signedin course tools export <%block name="content"> +
    +
    +
    + Tools +

    Course Export

    +
    +
    +
    +
    diff --git a/cms/templates/howitworks.html b/cms/templates/howitworks.html new file mode 100644 index 0000000000..1cf9b17710 --- /dev/null +++ b/cms/templates/howitworks.html @@ -0,0 +1,185 @@ +<%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> + +<%block name="title">Welcome +<%block name="bodyclass">not-signedin index howitworks + +<%block name="content"> + +
    +
    +
    +

    Welcome to

    +

    Studio helps manage your courses online, so you can focus on teaching them

    +
    +
    +
    + +
    +
    +
    +

    Studio's Many Features

    +
    + +
      +
    1. +
      + + Studio Helps You Keep Your Courses Organized +
      Studio Helps You Keep Your Courses Organized
      + + + +
      +
      + +
      +

      Keeping Your Course Organized

      +

      The backbone of your course is how it is organized. Studio offers an Outline editor, providing a simple hierarchy and easy drag and drop to help you and your students stay organized.

      + +
        +
      • +

        Simple Organization For Content

        +

        Studio uses a simple hierarchy of sections and subsections to organize your content.

        +
      • + +
      • +

        Change Your Mind Anytime

        +

        Draft your outline and build content anywhere. Simple drag and drop tools let your reorganize quickly.

        +
      • + +
      • +

        Go A Week Or A Semester At A Time

        +

        Build and release sections to your students incrementally. You don't have to have it all done at once.

        +
      • +
      +
      +
    2. + +
    3. +
      + + Learning is More than Just Lectures +
      Learning is More than Just Lectures
      + + + +
      +
      + +
      +

      Learning is More than Just Lectures

      +

      Studio lets you weave your content together in a way that reinforces learning — short video lectures interleaved with exercises and more. Insert videos and author a wide variety of exercise types with just a few clicks.

      + +
        +
      • +

        Create Learning Pathways

        +

        Help your students understand a small interactive piece at a time with multimedia, HTML, and exercises.

        +
      • + +
      • +

        Work Visually, Organize Quickly

        +

        Work visually and see exactly what your students will see. Reorganize all your content with drag and drop.

        +
      • + +
      • +

        A Broad Library of Problem Types

        +

        It's more than just multiple choice. Studio has nearly a dozen types of problems to challenge your learners.

        +
      • +
      +
      +
    4. + +
    5. +
      + + Studio Gives You Simple, Fast, and Incremental Publishing. With Friends. +
      Studio Gives You Simple, Fast, and Incremental Publishing. With Friends.
      + + + +
      +
      + +
      +

      Simple, Fast, and Incremental Publishing. With Friends.

      +

      Studio works like web applications you already know, yet understands how you build curriculum. Instant publishing to the web when you want it, incremental release when it makes sense. And with co-authors, you can have a whole team building a course, together.

      + +
        +
      • +

        Instant Changes

        +

        Caught a bug? No problem. When you want, your changes to live when you hit Save.

        +
      • + +
      • +

        Release-On Date Publishing

        +

        When you've finished a section, pick when you want it to go live and Studio takes care of the rest. Build your course incrementally.

        +
      • + +
      • +

        Work in Teams

        +

        Co-authors have full access to all the same authoring tools. Make your course better through a team effort.

        +
      • +
      +
      +
    6. +
    +
    +
    + +
    +
    +
    +

    Sign Up for Studio Today!

    +
    + + +
    +
    + +
    +

    Outlining Your Course

    +
    + +
    Simple two-level outline to organize your couse. Drag and drop, and see your course at a glance.
    +
    + + + + close modal + +
    + +
    +

    More than Just Lectures

    +
    + +
    Quickly create videos, text snippets, inline discussions, and a variety of problem types.
    +
    + + + + close modal + +
    + +
    +

    Publishing on Date

    +
    + +
    Simply set the date of a section or subsection, and Studio will publish it to your students for you.
    +
    + + + + close modal + +
    + \ No newline at end of file diff --git a/cms/templates/import.html b/cms/templates/import.html index e4f8019714..b0a9f04903 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -2,10 +2,19 @@ <%namespace name='static' file='static_content.html'/> <%! from django.core.urlresolvers import reverse %> -<%block name="title">Import -<%block name="bodyclass">import +<%block name="title">Import Course +<%block name="bodyclass">is-signedin course tools import <%block name="content"> +
    +
    +
    + Tools +

    Course Import

    +
    +
    +
    +
    diff --git a/cms/templates/index.html b/cms/templates/index.html index 45c4edc176..fdb46612a0 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -1,6 +1,7 @@ <%inherit file="base.html" /> -<%block name="bodyclass">index + <%block name="title">Courses +<%block name="bodyclass">is-signedin index dashboard <%block name="header_extras"> - - + \ No newline at end of file diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 99ac279bfb..722e756203 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -1,17 +1,31 @@ <%inherit file="base.html" /> <%block name="title">Course Staff Manager -<%block name="bodyclass">users +<%block name="bodyclass">is-signedin course users settings team + <%block name="content"> +
    +
    +
    + Course Settings +

    Course Team

    +
    + + +
    +
    +
    -
    - %if allow_actions: - - New User - - %endif -

    The following list of users have been designated as course staff. This means that these users will have permissions to modify course content. You may add additional course staff below, if you are the course instructor. Please note that they must have already registered and verified their account.

    diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 20ddcead01..91a1107726 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -6,7 +6,8 @@ from datetime import datetime %> <%! from django.core.urlresolvers import reverse %> -<%block name="title">CMS Courseware Overview +<%block name="title">Course Outline +<%block name="bodyclass">is-signedin course outline <%namespace name='static' file='static_content.html'/> <%namespace name="units" file="widgets/units.html" /> @@ -119,12 +120,32 @@
    +
    +
    +
    + Course Content +

    Course Outline

    +
    + + +
    +
    +
    -
    % for section in sections:
    diff --git a/cms/templates/settings.html b/cms/templates/settings.html index c96d5686fd..d2ce4a6de9 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -1,6 +1,6 @@ <%inherit file="base.html" /> -<%block name="bodyclass">settings -<%block name="title">Settings +<%block name="title">Schedule & Details +<%block name="bodyclass">is-signedin course schedule settings <%namespace name='static' file='static_content.html'/> <%! @@ -15,24 +15,25 @@ from contentstore import utils - - - - - + + + + + + + + + + + + +<%block name="content"> +
    +
    +
    + Settings +

    Advanced Settings

    +
    + +
    +
    + +
    + Your policy changes have been saved. +
    + +
    + There was an error saving your information. Please see below. +
    + +
    +
    +

    Manual Policy Definition

    + Manually Edit Course Policy Values (JSON Key / Value pairs) +
    + +

    Warning: Add only manual policy data that you are familiar + with.

    + +
      + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +

    Note: Your changes will not take effect until you save your + progress. Take care with key and value formatting, as validation is not implemented.

    +
    + +
    + +
    +
    +
    + \ No newline at end of file diff --git a/cms/templates/settings_discussions_faculty.html b/cms/templates/settings_discussions_faculty.html new file mode 100644 index 0000000000..fc30b6eebb --- /dev/null +++ b/cms/templates/settings_discussions_faculty.html @@ -0,0 +1,430 @@ + +<%inherit file="base.html" /> +<%block name="title">Schedule and details +<%block name="bodyclass">is-signedin course settings + + +<%namespace name='static' file='static_content.html'/> +<%! +from contentstore import utils +%> + + +<%block name="jsextra"> + + + + + + + + +<%block name="content"> + +
    +
    +

    Settings

    +
    +
    + +
    +

    Faculty

    + +
    +
    +

    Faculty Members

    + Individuals instructing and help with this course +
    + +
    +
    +
      +
    • +
      + +
      + +
      +
      + +
      + +
      + +
      +
      + +
      + + +
      + +
      + +
      + + A brief description of your education, experience, and expertise +
      +
      + + Delete Faculty Member +
    • + +
    • +
      + +
      + +
      +
      + +
      + +
      + +
      +
      + +
      + +
      +
      + + Upload Faculty Photo + + Max size: 30KB +
      +
      +
      + +
      + +
      +
      + + A brief description of your education, experience, and expertise +
      +
      +
      +
    • +
    + + + New Faculty Member + +
    +
    +
    + +
    + +
    +

    Problems

    + +
    +
    +

    General Settings

    + Course-wide settings for all problems +
    + +
    +

    Problem Randomization:

    + +
    +
    + + +
    + + randomize all problems +
    +
    + +
    + + +
    + + do not randomize problems +
    +
    + +
    + + +
    + + randomize problems per student +
    +
    +
    +
    + +
    +

    Show Answers:

    + +
    +
    + + +
    + + Answers will be shown after the number of attempts has been met +
    +
    + +
    + + +
    + + Answers will never be shown, regardless of attempts +
    +
    +
    +
    + +
    + + +
    +
    + + Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0" +
    +
    +
    +
    + +
    +
    +

    [Assignment Type Name]

    +
    + +
    +

    Problem Randomization:

    + +
    +
    + + +
    + + randomize all problems +
    +
    + +
    + + +
    + + do not randomize problems +
    +
    + +
    + + +
    + + randomize problems per student +
    +
    +
    +
    + +
    +

    Show Answers:

    + +
    +
    + + +
    + + Answers will be shown after the number of attempts has been met +
    +
    + +
    + + +
    + + Answers will never be shown, regardless of attempts +
    +
    +
    +
    + +
    + + +
    +
    + + Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0" +
    +
    +
    +
    +
    + +
    +

    Discussions

    + +
    +
    +

    General Settings

    + Course-wide settings for online discussion +
    + +
    +

    Anonymous Discussions:

    + +
    +
    + + +
    + + Students and faculty will be able to post anonymously +
    +
    + +
    + + +
    + + Posting anonymously is not allowed. Any previous anonymous posts will be reverted to non-anonymous +
    +
    +
    +
    + +
    +

    Anonymous Discussions:

    + +
    +
    + + +
    + + Students and faculty will be able to post anonymously +
    +
    + +
    + + +
    + + This option is disabled since there are previous discussions that are anonymous. +
    +
    +
    +
    + +
    +

    Discussion Categories

    + +
    + + + + New Discussion Category + +
    +
    +
    +
    +
    +
    +
    +
    +
    + diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html new file mode 100644 index 0000000000..923cc35313 --- /dev/null +++ b/cms/templates/settings_graders.html @@ -0,0 +1,152 @@ +<%inherit file="base.html" /> +<%block name="title">Grading +<%block name="bodyclass">is-signedin course grading settings + +<%namespace name='static' file='static_content.html'/> +<%! +from contentstore import utils +%> + +<%block name="jsextra"> + + + + + + + + + + + + + +<%block name="content"> +
    +
    +
    + Settings +

    Grading

    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +

    Overall Grade Range

    + Your overall grading scale for student final grades +
    + +
      +
    1. +
      + +
      +
      +
        +
      1. 0
      2. +
      3. 10
      4. +
      5. 20
      6. +
      7. 30
      8. +
      9. 40
      10. +
      11. 50
      12. +
      13. 60
      14. +
      15. 70
      16. +
      17. 80
      18. +
      19. 90
      20. +
      21. 100
      22. +
      +
        +
      +
      +
      +
      +
    2. +
    +
    + +
    + +
    +
    +

    Grading Rules & Policies

    + Deadlines, requirements, and logistics around grading student work +
    + +
      +
    1. + + + Leeway on due dates +
    2. +
    +
    + +
    + +
    +
    +

    Assignment Types

    + Categories and labels for any exercises that are gradable +
    + +
      + +
    + + +
    +
    +
    + + +
    +
    + diff --git a/cms/templates/signup.html b/cms/templates/signup.html index 2c60b758e6..30c5c1cf2b 100644 --- a/cms/templates/signup.html +++ b/cms/templates/signup.html @@ -1,94 +1,141 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> -<%block name="title">Sign up -<%block name="bodyclass">no-header +<%block name="title">Sign Up +<%block name="bodyclass">not-signedin signup <%block name="content"> -
    +
    +
    +
    +

    Sign Up for edX Studio

    + +
    - +
    +

    I've never authored a course online before. Is there help?

    +

    Absolutely. We have created an online course, edX101, that describes some best practices: from filming video, creating exercises, to the basics of running an online course. Additionally, we're always here to help, just drop us a note.

    +
    + +
    +
    + - + ); + }); + })(this) + \ No newline at end of file diff --git a/cms/templates/unit.html b/cms/templates/unit.html index f3a779604e..c529f5863a 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -1,8 +1,9 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> <%namespace name="units" file="widgets/units.html" /> -<%block name="bodyclass">unit -<%block name="title">CMS Unit +<%block name="title">Individual Unit +<%block name="bodyclass">is-signedin course unit + <%block name="jsextra"> @@ -56,38 +65,66 @@
    % for type, templates in sorted(component_templates.items()):
    -

    Select ${type} component type:

    - - + % if type == "problem": +
    + + % endif +
    +
      + % for name, location, has_markdown, is_empty in templates: + % if has_markdown or type != "problem": + % if is_empty: +
    • + + ${name} + +
    • + + % else: +
    • + + ${name} + +
    • + % endif + % endif + + %endfor +
    +
    + % if type == "problem": +
    +
      + % for name, location, has_markdown, is_empty in templates: + % if not has_markdown: + % if is_empty: +
    • + + ${name} + +
    • + + % else: +
    • + + ${name} + + +
    • + % endif + % endif + % endfor +
    +
    +
    + % endif Cancel
    % endfor diff --git a/cms/templates/widgets/footer.html b/cms/templates/widgets/footer.html new file mode 100644 index 0000000000..0f265dfc2c --- /dev/null +++ b/cms/templates/widgets/footer.html @@ -0,0 +1,30 @@ +<%! from django.core.urlresolvers import reverse %> + + \ No newline at end of file diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 5f41452339..a063e4b526 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -1,40 +1,117 @@ <%! from django.core.urlresolvers import reverse %> -<% active_tab_class = 'active-tab-' + active_tab if active_tab else '' %> -
    -
    -
    -
    - % if context_course: - <% ctx_loc = context_course.location %> - › - ${context_course.display_name} › - % endif -
    +
    + + +
    + % if user.is_authenticated(): + + % else: + + % endif +
    +
    +
    \ No newline at end of file diff --git a/cms/templates/widgets/problem-edit.html b/cms/templates/widgets/problem-edit.html index 4ff9d299ab..8ca07a7928 100644 --- a/cms/templates/widgets/problem-edit.html +++ b/cms/templates/widgets/problem-edit.html @@ -1,20 +1,20 @@ <%include file="metadata-edit.html" />
    - %if markdown != '' or data == '\n\n': + %if enable_markdown:
    • -
    • -
    • -
    • -
    • @@ -56,7 +56,7 @@
    -
    Check Multiple
    +
    Checkboxes
    @@ -67,7 +67,7 @@
    -
    String Response
    +
    Text Input
    @@ -76,7 +76,7 @@
    -
    Numerical Response
    +
    Numerical Input
    @@ -85,7 +85,7 @@
    -
    Option Response
    +
    Dropdown
    diff --git a/cms/templates/widgets/source-edit.html b/cms/templates/widgets/source-edit.html index f0922831e1..c7460c9cf7 100644 --- a/cms/templates/widgets/source-edit.html +++ b/cms/templates/widgets/source-edit.html @@ -10,7 +10,7 @@

    High Level Source Editing

    -
    +
    @@ -18,6 +18,9 @@ + + +
    @@ -25,88 +28,148 @@ - diff --git a/cms/urls.py b/cms/urls.py index ad4dd87d74..7b7b5e9375 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -6,7 +6,8 @@ from django.conf.urls import patterns, include, url # admin.autodiscover() urlpatterns = ('', - url(r'^$', 'contentstore.views.index', name='index'), + url(r'^$', 'contentstore.views.howitworks', name='homepage'), + url(r'^listing', 'contentstore.views.index', name='index'), url(r'^edit/(?P.*?)$', 'contentstore.views.edit_unit', name='edit_unit'), url(r'^subsection/(?P.*?)$', 'contentstore.views.edit_subsection', name='edit_subsection'), url(r'^preview_component/(?P.*?)$', 'contentstore.views.preview_component', name='preview_component'), @@ -42,9 +43,14 @@ urlpatterns = ('', 'contentstore.views.remove_user', name='remove_user'), url(r'^(?P[^/]+)/(?P[^/]+)/info/(?P[^/]+)$', 'contentstore.views.course_info', name='course_info'), url(r'^(?P[^/]+)/(?P[^/]+)/course_info/updates/(?P.*)$', 'contentstore.views.course_info_updates', name='course_info'), - url(r'^(?P[^/]+)/(?P[^/]+)/settings/(?P[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'), - url(r'^(?P[^/]+)/(?P[^/]+)/settings/(?P[^/]+)/section/(?P
    [^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'), - url(r'^(?P[^/]+)/(?P[^/]+)/grades/(?P[^/]+)/(?P.*)$', 'contentstore.views.course_grader_updates', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-details/(?P[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-grading/(?P[^/]+)$', 'contentstore.views.course_config_graders_page', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-details/(?P[^/]+)/section/(?P
    [^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-grading/(?P[^/]+)/(?P.*)$', 'contentstore.views.course_grader_updates', name='course_settings'), + # This is the URL to initially render the course advanced settings. + url(r'^(?P[^/]+)/(?P[^/]+)/settings-advanced/(?P[^/]+)$', 'contentstore.views.course_config_advanced_page', name='course_advanced_settings'), + # This is the URL used by BackBone for updating and re-fetching the model. + url(r'^(?P[^/]+)/(?P[^/]+)/settings-advanced/(?P[^/]+)/update.*$', 'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'), url(r'^(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/gradeas.*$', 'contentstore.views.assignment_type_update', name='assignment_type_update'), @@ -76,13 +82,15 @@ urlpatterns = ('', # User creation and updating views urlpatterns += ( + url(r'^howitworks$', 'contentstore.views.howitworks', name='howitworks'), url(r'^signup$', 'contentstore.views.signup', name='signup'), url(r'^create_account$', 'student.views.create_account'), url(r'^activate/(?P[^/]*)$', 'student.views.activate_account', name='activate'), # form page - url(r'^login$', 'contentstore.views.login_page', name='login'), + url(r'^login$', 'contentstore.views.old_login_redirect', name='old_login'), + url(r'^signin$', 'contentstore.views.login_page', name='login'), # ajax view that actually does the work url(r'^login_post$', 'student.views.login_user', name='login_post'), diff --git a/common/djangoapps/course_groups/cohorts.py b/common/djangoapps/course_groups/cohorts.py index 155f82e0c7..f0234ec71a 100644 --- a/common/djangoapps/course_groups/cohorts.py +++ b/common/djangoapps/course_groups/cohorts.py @@ -6,6 +6,7 @@ forums, and to the cohort admin views. from django.contrib.auth.models import User from django.http import Http404 import logging +import random from courseware import courses from student.models import get_user_by_username_or_email @@ -64,7 +65,23 @@ def is_commentable_cohorted(course_id, commentable_id): ans)) return ans + +def get_cohorted_commentables(course_id): + """ + Given a course_id return a list of strings representing cohorted commentables + """ + course = courses.get_course_by_id(course_id) + + if not course.is_cohorted: + # this is the easy case :) + ans = [] + else: + ans = course.cohorted_discussions + + return ans + + def get_cohort(user, course_id): """ Given a django User and a course_id, return the user's cohort in that @@ -96,9 +113,30 @@ def get_cohort(user, course_id): group_type=CourseUserGroup.COHORT, users__id=user.id) except CourseUserGroup.DoesNotExist: - # TODO: add auto-cohorting logic here once we know what that will be. + # Didn't find the group. We'll go on to create one if needed. + pass + + if not course.auto_cohort: return None + choices = course.auto_cohort_groups + if len(choices) == 0: + # Nowhere to put user + log.warning("Course %s is auto-cohorted, but there are no" + " auto_cohort_groups specified", + course_id) + return None + + # Put user in a random group, creating it if needed + group_name = random.choice(choices) + group, created = CourseUserGroup.objects.get_or_create( + course_id=course_id, + group_type=CourseUserGroup.COHORT, + name=group_name) + + user.course_groups.add(group) + return group + def get_course_cohorts(course_id): """ diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index 0fbf863fee..efed39d536 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -2,7 +2,7 @@ import django.test from django.contrib.auth.models import User from django.conf import settings -from override_settings import override_settings +from django.test.utils import override_settings from course_groups.models import CourseUserGroup from course_groups.cohorts import (get_cohort, get_course_cohorts, @@ -47,7 +47,10 @@ class TestCohorts(django.test.TestCase): @staticmethod def config_course_cohorts(course, discussions, - cohorted, cohorted_discussions=None): + cohorted, + cohorted_discussions=None, + auto_cohort=None, + auto_cohort_groups=None): """ Given a course with no discussion set up, add the discussions and set the cohort config appropriately. @@ -59,6 +62,9 @@ class TestCohorts(django.test.TestCase): cohorted: bool. cohorted_discussions: optional list of topic names. If specified, converts them to use the same ids as topic names. + auto_cohort: optional bool. + auto_cohort_groups: optional list of strings + (names of groups to put students into). Returns: Nothing -- modifies course in place. @@ -76,6 +82,12 @@ class TestCohorts(django.test.TestCase): if cohorted_discussions is not None: d["cohorted_discussions"] = [to_id(name) for name in cohorted_discussions] + + if auto_cohort is not None: + d["auto_cohort"] = auto_cohort + if auto_cohort_groups is not None: + d["auto_cohort_groups"] = auto_cohort_groups + course.metadata["cohort_config"] = d @@ -89,12 +101,9 @@ class TestCohorts(django.test.TestCase): def test_get_cohort(self): - # Need to fix this, but after we're testing on staging. (Looks like - # problem is that when get_cohort internally tries to look up the - # course.id, it fails, even though we loaded it through the modulestore. - - # Proper fix: give all tests a standard modulestore that uses the test - # dir. + """ + Make sure get_cohort() does the right thing when the course is cohorted + """ course = modulestore().get_course("edX/toy/2012_Fall") self.assertEqual(course.id, "edX/toy/2012_Fall") self.assertFalse(course.is_cohorted) @@ -122,6 +131,54 @@ class TestCohorts(django.test.TestCase): self.assertEquals(get_cohort(other_user, course.id), None, "other_user shouldn't have a cohort") + def test_auto_cohorting(self): + """ + Make sure get_cohort() does the right thing when the course is auto_cohorted + """ + course = modulestore().get_course("edX/toy/2012_Fall") + self.assertEqual(course.id, "edX/toy/2012_Fall") + self.assertFalse(course.is_cohorted) + + user1 = User.objects.create(username="test", email="a@b.com") + user2 = User.objects.create(username="test2", email="a2@b.com") + user3 = User.objects.create(username="test3", email="a3@b.com") + + cohort = CourseUserGroup.objects.create(name="TestCohort", + course_id=course.id, + group_type=CourseUserGroup.COHORT) + + # user1 manually added to a cohort + cohort.users.add(user1) + + # Make the course auto cohorted... + self.config_course_cohorts(course, [], cohorted=True, + auto_cohort=True, + auto_cohort_groups=["AutoGroup"]) + + self.assertEquals(get_cohort(user1, course.id).id, cohort.id, + "user1 should stay put") + + self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup", + "user2 should be auto-cohorted") + + # Now make the group list empty + self.config_course_cohorts(course, [], cohorted=True, + auto_cohort=True, + auto_cohort_groups=[]) + + self.assertEquals(get_cohort(user3, course.id), None, + "No groups->no auto-cohorting") + + # Now make it different + self.config_course_cohorts(course, [], cohorted=True, + auto_cohort=True, + auto_cohort_groups=["OtherGroup"]) + + self.assertEquals(get_cohort(user3, course.id).name, "OtherGroup", + "New list->new group") + self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup", + "user2 should still be in originally placed cohort") + def test_get_course_cohorts(self): course1_id = 'a/b/c' diff --git a/common/djangoapps/mitxmako/makoloader.py b/common/djangoapps/mitxmako/makoloader.py index 29184299b6..d623e8bcff 100644 --- a/common/djangoapps/mitxmako/makoloader.py +++ b/common/djangoapps/mitxmako/makoloader.py @@ -9,6 +9,7 @@ from django.template.loaders.app_directories import Loader as AppDirectoriesLoad from mitxmako.template import Template import mitxmako.middleware +import tempdir log = logging.getLogger(__name__) @@ -30,7 +31,7 @@ class MakoLoader(object): if module_directory is None: log.warning("For more caching of mako templates, set the MAKO_MODULE_DIR in settings!") - module_directory = tempfile.mkdtemp() + module_directory = tempdir.mkdtemp_clean() self.module_directory = module_directory diff --git a/common/djangoapps/mitxmako/middleware.py b/common/djangoapps/mitxmako/middleware.py index 64cb2e5415..3f66f8cc48 100644 --- a/common/djangoapps/mitxmako/middleware.py +++ b/common/djangoapps/mitxmako/middleware.py @@ -13,7 +13,7 @@ # limitations under the License. from mako.lookup import TemplateLookup -import tempfile +import tempdir from django.template import RequestContext from django.conf import settings @@ -29,7 +29,7 @@ class MakoMiddleware(object): module_directory = getattr(settings, 'MAKO_MODULE_DIR', None) if module_directory is None: - module_directory = tempfile.mkdtemp() + module_directory = tempdir.mkdtemp_clean() for location in template_locations: lookup[location] = TemplateLookup(directories=template_locations[location], diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index d41688530e..fb1f48d143 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -13,12 +13,18 @@ log = logging.getLogger(__name__) def _url_replace_regex(prefix): + """ + Match static urls in quotes that don't end in '?raw'. + + To anyone contemplating making this more complicated: + http://xkcd.com/1171/ + """ return r""" - (?x) # flags=re.VERBOSE - (?P\\?['"]) # the opening quotes - (?P{prefix}) # theeprefix - (?P.*?) # everything else in the url - (?P=quote) # the first matching closing quote + (?x) # flags=re.VERBOSE + (?P\\?['"]) # the opening quotes + (?P{prefix}) # the prefix + (?P.*?) # everything else in the url + (?P=quote) # the first matching closing quote """.format(prefix=prefix) @@ -74,6 +80,10 @@ def replace_static_urls(text, data_directory, course_namespace=None): quote = match.group('quote') rest = match.group('rest') + # Don't mess with things that end in '?raw' + if rest.endswith('?raw'): + return original + # course_namespace is not None, then use studio style urls if course_namespace is not None and not isinstance(modulestore(), XMLModuleStore): url = StaticContent.convert_legacy_static_url(rest, course_namespace) diff --git a/common/djangoapps/static_replace/test/test_static_replace.py b/common/djangoapps/static_replace/test/test_static_replace.py index 98c29ca2f9..f23610e1bd 100644 --- a/common/djangoapps/static_replace/test/test_static_replace.py +++ b/common/djangoapps/static_replace/test/test_static_replace.py @@ -1,5 +1,8 @@ -from nose.tools import assert_equals -from static_replace import replace_static_urls, replace_course_urls +import re + +from nose.tools import assert_equals, assert_true, assert_false +from static_replace import (replace_static_urls, replace_course_urls, + _url_replace_regex) from mock import patch, Mock from xmodule.modulestore import Location from xmodule.modulestore.mongo import MongoModuleStore @@ -75,3 +78,34 @@ def test_data_dir_fallback(mock_storage, mock_modulestore, mock_settings): mock_storage.exists.return_value = False assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY)) + + +def test_raw_static_check(): + """ + Make sure replace_static_urls leaves alone things that end in '.raw' + """ + path = '"/static/foo.png?raw"' + assert_equals(path, replace_static_urls(path, DATA_DIRECTORY)) + + text = 'text
    = 0, 'error message "{}" did not contain "{}"'.format(error_message, expected)) + def setUp(self): + self.import_dir = mkdtemp(prefix="import") + self.addCleanup(shutil.rmtree, self.import_dir) + self.export_dir = mkdtemp(prefix="export") + self.addCleanup(shutil.rmtree, self.export_dir) + def tearDown(self): - def delete_temp_dir(dirname): - if os.path.exists(dirname): - for filename in os.listdir(dirname): - os.remove(os.path.join(dirname, filename)) - os.rmdir(dirname) - - # clean up after any test data was dumped to temp directory - delete_temp_dir(self.import_dir) - delete_temp_dir(self.export_dir) - + pass # and clean up the database: # TestCenterUser.objects.all().delete() # TestCenterRegistration.objects.all().delete() diff --git a/lms/djangoapps/terrain/__init__.py b/common/djangoapps/terrain/__init__.py similarity index 100% rename from lms/djangoapps/terrain/__init__.py rename to common/djangoapps/terrain/__init__.py diff --git a/lms/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py similarity index 87% rename from lms/djangoapps/terrain/browser.py rename to common/djangoapps/terrain/browser.py index e1925bde0b..8c2a8ba7a5 100644 --- a/lms/djangoapps/terrain/browser.py +++ b/common/djangoapps/terrain/browser.py @@ -11,8 +11,9 @@ from django.core.management import call_command @before.harvest def initial_setup(server): - # Launch firefox + # Launch the browser app (choose one of these below) world.browser = Browser('chrome') + # world.browser = Browser('firefox') @before.each_scenario diff --git a/lms/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py similarity index 96% rename from lms/djangoapps/terrain/factories.py rename to common/djangoapps/terrain/factories.py index 896f115df5..bb7ae012c8 100644 --- a/lms/djangoapps/terrain/factories.py +++ b/common/djangoapps/terrain/factories.py @@ -1,4 +1,5 @@ from student.models import User, UserProfile, Registration +from django.contrib.auth.models import Group from datetime import datetime from factory import Factory from xmodule.modulestore import Location @@ -8,6 +9,12 @@ from uuid import uuid4 from xmodule.timeparse import stringify_time +class GroupFactory(Factory): + FACTORY_FOR = Group + + name = 'staff_MITx/999/Robot_Super_Course' + + class UserProfileFactory(Factory): FACTORY_FOR = UserProfile diff --git a/lms/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py similarity index 96% rename from lms/djangoapps/terrain/steps.py rename to common/djangoapps/terrain/steps.py index 6b2a813d8d..88fba697b2 100644 --- a/lms/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -1,8 +1,6 @@ from lettuce import world, step from factories import * -from django.core.management import call_command from lettuce.django import django_url -from django.conf import settings from django.contrib.auth.models import User from student.models import CourseEnrollment from urllib import quote_plus @@ -21,6 +19,11 @@ def wait(step, seconds): time.sleep(float(seconds)) +@step('I reload the page$') +def reload_the_page(step): + world.browser.reload() + + @step('I (?:visit|access|open) the homepage$') def i_visit_the_homepage(step): world.browser.visit(django_url('/')) @@ -105,6 +108,11 @@ def i_am_an_edx_user(step): #### helper functions +@world.absorb +def scroll_to_bottom(): + # Maximize the browser + world.browser.execute_script("window.scrollTo(0, screen.height);") + @world.absorb def create_user(uname): diff --git a/common/djangoapps/util/converters.py b/common/djangoapps/util/converters.py index 900371a0dd..ec2d29ecfa 100644 --- a/common/djangoapps/util/converters.py +++ b/common/djangoapps/util/converters.py @@ -18,10 +18,13 @@ def jsdate_to_time(field): """ if field is None: return field - elif isinstance(field, basestring): # iso format but ignores time zone assuming it's Z - d = datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable + elif isinstance(field, basestring): + # ISO format but ignores time zone assuming it's Z. + d = datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable return d.utctimetuple() - elif isinstance(field, int) or isinstance(field, float): + elif isinstance(field, (int, long, float)): return time.gmtime(field / 1000) elif isinstance(field, time.struct_time): return field + else: + raise ValueError("Couldn't convert %r to time" % field) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index ad084bdaf7..a1a4e6b65e 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -632,8 +632,14 @@ class MultipleChoiceResponse(LoncapaResponse): # define correct choices (after calling secondary setup) xml = self.xml - cxml = xml.xpath('//*[@id=$id]//choice[@correct="true"]', id=xml.get('id')) - self.correct_choices = [contextualize_text(choice.get('name'), self.context) for choice in cxml] + cxml = xml.xpath('//*[@id=$id]//choice', id=xml.get('id')) + + # contextualize correct attribute and then select ones for which + # correct = "true" + self.correct_choices = [ + contextualize_text(choice.get('name'), self.context) + for choice in cxml + if contextualize_text(choice.get('correct'), self.context) == "true"] def mc_setup_response(self): ''' diff --git a/common/lib/capa/capa/templates/codeinput.html b/common/lib/capa/capa/templates/codeinput.html index 5c2ff2aca5..eb8cad0d70 100644 --- a/common/lib/capa/capa/templates/codeinput.html +++ b/common/lib/capa/capa/templates/codeinput.html @@ -50,6 +50,7 @@ }, smartIndent: false }); + $("#textbox_${id}").find('.CodeMirror-scroll').height(${int(13.5*eval(rows))}); });
    diff --git a/common/lib/capa/capa/templates/designprotein2dinput.html b/common/lib/capa/capa/templates/designprotein2dinput.html index ff845f8713..6733566ab9 100644 --- a/common/lib/capa/capa/templates/designprotein2dinput.html +++ b/common/lib/capa/capa/templates/designprotein2dinput.html @@ -1,5 +1,5 @@
    -
    +
    % if status == 'unsubmitted': diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py new file mode 100644 index 0000000000..fe918ec5db --- /dev/null +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -0,0 +1,668 @@ +from lxml import etree +from abc import ABCMeta, abstractmethod + +class ResponseXMLFactory(object): + """ Abstract base class for capa response XML factories. + Subclasses override create_response_element and + create_input_element to produce XML of particular response types""" + + __metaclass__ = ABCMeta + + @abstractmethod + def create_response_element(self, **kwargs): + """ Subclasses override to return an etree element + representing the capa response XML + (e.g. ). + + The tree should NOT contain any input elements + (such as ) as these will be added later.""" + return None + + @abstractmethod + def create_input_element(self, **kwargs): + """ Subclasses override this to return an etree element + representing the capa input XML (such as )""" + return None + + def build_xml(self, **kwargs): + """ Construct an XML string for a capa response + based on **kwargs. + + **kwargs is a dictionary that will be passed + to create_response_element() and create_input_element(). + See the subclasses below for other keyword arguments + you can specify. + + For all response types, **kwargs can contain: + + *question_text*: The text of the question to display, + wrapped in

    tags. + + *explanation_text*: The detailed explanation that will + be shown if the user answers incorrectly. + + *script*: The embedded Python script (a string) + + *num_responses*: The number of responses to create [DEFAULT: 1] + + *num_inputs*: The number of input elements + to create [DEFAULT: 1] + + Returns a string representation of the XML tree. + """ + + # Retrieve keyward arguments + question_text = kwargs.get('question_text', '') + explanation_text = kwargs.get('explanation_text', '') + script = kwargs.get('script', None) + num_responses = kwargs.get('num_responses', 1) + num_inputs = kwargs.get('num_inputs', 1) + + # The root is + root = etree.Element("problem") + + # Add a script if there is one + if script: + script_element = etree.SubElement(root, "script") + script_element.set("type", "loncapa/python") + script_element.text = str(script) + + # The problem has a child

    with question text + question = etree.SubElement(root, "p") + question.text = question_text + + # Add the response(s) + for i in range(0, int(num_responses)): + response_element = self.create_response_element(**kwargs) + root.append(response_element) + + # Add input elements + for j in range(0, int(num_inputs)): + input_element = self.create_input_element(**kwargs) + if not (None == input_element): + response_element.append(input_element) + + # The problem has an explanation of the solution + if explanation_text: + explanation = etree.SubElement(root, "solution") + explanation_div = etree.SubElement(explanation, "div") + explanation_div.set("class", "detailed-solution") + explanation_div.text = explanation_text + + return etree.tostring(root) + + @staticmethod + def textline_input_xml(**kwargs): + """ Create a XML element + + Uses **kwargs: + + *math_display*: If True, then includes a MathJax display of user input + + *size*: An integer representing the width of the text line + """ + math_display = kwargs.get('math_display', False) + size = kwargs.get('size', None) + + input_element = etree.Element('textline') + + if math_display: + input_element.set('math', '1') + + if size: + input_element.set('size', str(size)) + + return input_element + + @staticmethod + def choicegroup_input_xml(**kwargs): + """ Create a XML element + + Uses **kwargs: + + *choice_type*: Can be "checkbox", "radio", or "multiple" + + *choices*: List of True/False values indicating whether + a particular choice is correct or not. + Users must choose *all* correct options in order + to be marked correct. + DEFAULT: [True] + + *choice_names": List of strings identifying the choices. + If specified, you must ensure that + len(choice_names) == len(choices) + """ + # Names of group elements + group_element_names = {'checkbox': 'checkboxgroup', + 'radio': 'radiogroup', + 'multiple': 'choicegroup' } + + # Retrieve **kwargs + choices = kwargs.get('choices', [True]) + choice_type = kwargs.get('choice_type', 'multiple') + choice_names = kwargs.get('choice_names', [None] * len(choices)) + + # Create the , , or element + assert(choice_type in group_element_names) + group_element = etree.Element(group_element_names[choice_type]) + + # Create the elements + for (correct_val, name) in zip(choices, choice_names): + choice_element = etree.SubElement(group_element, "choice") + choice_element.set("correct", "true" if correct_val else "false") + + # Add some text describing the choice + etree.SubElement(choice_element, "startouttext") + etree.text = "Choice description" + etree.SubElement(choice_element, "endouttext") + + # Add a name identifying the choice, if one exists + if name: + choice_element.set("name", str(name)) + + return group_element + + +class NumericalResponseXMLFactory(ResponseXMLFactory): + """ Factory for producing XML trees """ + + def create_response_element(self, **kwargs): + """ Create a XML element. + Uses **kwarg keys: + + *answer*: The correct answer (e.g. "5") + + *tolerance*: The tolerance within which a response + is considered correct. Can be a decimal (e.g. "0.01") + or percentage (e.g. "2%") + """ + + answer = kwargs.get('answer', None) + tolerance = kwargs.get('tolerance', None) + + response_element = etree.Element('numericalresponse') + + if answer: + response_element.set('answer', str(answer)) + + if tolerance: + responseparam_element = etree.SubElement(response_element, 'responseparam') + responseparam_element.set('type', 'tolerance') + responseparam_element.set('default', str(tolerance)) + + return response_element + + def create_input_element(self, **kwargs): + return ResponseXMLFactory.textline_input_xml(**kwargs) + + +class CustomResponseXMLFactory(ResponseXMLFactory): + """ Factory for producing XML trees """ + + def create_response_element(self, **kwargs): + """ Create a XML element. + + Uses **kwargs: + + *cfn*: the Python code to run. Can be inline code, + or the name of a function defined in earlier - - -

    Hints can be provided to students, based on the last response given, as well as the history of responses given. Here is an example of a hint produced by a Formula Response problem.

    - -

    -What is the equation of the line which passess through ($x1,$y1) and -($x2,$y2)?

    - -

    The correct answer is $answer. A common error is to invert the equation for the slope. Enter -$wrongans to see a hint.

    - - - - - - y = - - - - - You have inverted the slope in the question. - - - - - diff --git a/common/lib/capa/capa/tests/test_files/imageresponse.xml b/common/lib/capa/capa/tests/test_files/imageresponse.xml deleted file mode 100644 index 41c9f01218..0000000000 --- a/common/lib/capa/capa/tests/test_files/imageresponse.xml +++ /dev/null @@ -1,40 +0,0 @@ - -

    -Two skiers are on frictionless black diamond ski slopes. -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.

    -
    -
    - - - - - - - -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.

    -
    -
    - - -
    diff --git a/common/lib/capa/capa/tests/test_files/javascriptresponse.xml b/common/lib/capa/capa/tests/test_files/javascriptresponse.xml deleted file mode 100644 index 439866e62c..0000000000 --- a/common/lib/capa/capa/tests/test_files/javascriptresponse.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/common/lib/capa/capa/tests/test_files/js/compiled/c9a9cd4242d84c924fe5f8324e9ae79d.js b/common/lib/capa/capa/tests/test_files/js/compiled/c9a9cd4242d84c924fe5f8324e9ae79d.js deleted file mode 100644 index 6670c6a09a..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/compiled/c9a9cd4242d84c924fe5f8324e9ae79d.js +++ /dev/null @@ -1,50 +0,0 @@ -// Generated by CoffeeScript 1.3.3 -(function() { - var MinimaxProblemDisplay, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - MinimaxProblemDisplay = (function(_super) { - - __extends(MinimaxProblemDisplay, _super); - - function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) { - this.state = state; - this.submission = submission; - this.evaluation = evaluation; - this.container = container; - this.submissionField = submissionField; - this.parameters = parameters != null ? parameters : {}; - MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters); - } - - MinimaxProblemDisplay.prototype.render = function() {}; - - MinimaxProblemDisplay.prototype.createSubmission = function() { - var id, value, _ref, _results; - this.newSubmission = {}; - if (this.submission != null) { - _ref = this.submission; - _results = []; - for (id in _ref) { - value = _ref[id]; - _results.push(this.newSubmission[id] = value); - } - return _results; - } - }; - - MinimaxProblemDisplay.prototype.getCurrentSubmission = function() { - return this.newSubmission; - }; - - return MinimaxProblemDisplay; - - })(XProblemDisplay); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.TestProblemDisplay = TestProblemDisplay; - -}).call(this); -; diff --git a/common/lib/capa/capa/tests/test_files/js/compiled/javascriptresponse.js b/common/lib/capa/capa/tests/test_files/js/compiled/javascriptresponse.js deleted file mode 100644 index 6670c6a09a..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/compiled/javascriptresponse.js +++ /dev/null @@ -1,50 +0,0 @@ -// Generated by CoffeeScript 1.3.3 -(function() { - var MinimaxProblemDisplay, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - MinimaxProblemDisplay = (function(_super) { - - __extends(MinimaxProblemDisplay, _super); - - function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) { - this.state = state; - this.submission = submission; - this.evaluation = evaluation; - this.container = container; - this.submissionField = submissionField; - this.parameters = parameters != null ? parameters : {}; - MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters); - } - - MinimaxProblemDisplay.prototype.render = function() {}; - - MinimaxProblemDisplay.prototype.createSubmission = function() { - var id, value, _ref, _results; - this.newSubmission = {}; - if (this.submission != null) { - _ref = this.submission; - _results = []; - for (id in _ref) { - value = _ref[id]; - _results.push(this.newSubmission[id] = value); - } - return _results; - } - }; - - MinimaxProblemDisplay.prototype.getCurrentSubmission = function() { - return this.newSubmission; - }; - - return MinimaxProblemDisplay; - - })(XProblemDisplay); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.TestProblemDisplay = TestProblemDisplay; - -}).call(this); -; diff --git a/common/lib/capa/capa/tests/test_files/multi_bare.xml b/common/lib/capa/capa/tests/test_files/multi_bare.xml deleted file mode 100644 index 20bc8f853d..0000000000 --- a/common/lib/capa/capa/tests/test_files/multi_bare.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - This is foil One. - - - This is foil Two. - - - This is foil Three. - - - This is foil Four. - - - This is foil Five. - - - - diff --git a/common/lib/capa/capa/tests/test_files/multichoice.xml b/common/lib/capa/capa/tests/test_files/multichoice.xml deleted file mode 100644 index 60bf02ec59..0000000000 --- a/common/lib/capa/capa/tests/test_files/multichoice.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - This is foil One. - - - This is foil Two. - - - This is foil Three. - - - This is foil Four. - - - This is foil Five. - - - - diff --git a/common/lib/capa/capa/tests/test_files/optionresponse.xml b/common/lib/capa/capa/tests/test_files/optionresponse.xml deleted file mode 100644 index 99a17e8fac..0000000000 --- a/common/lib/capa/capa/tests/test_files/optionresponse.xml +++ /dev/null @@ -1,63 +0,0 @@ - - -

    -Why do bicycles benefit from having larger wheels when going up a bump as shown in the picture?
    -Assume that for both bicycles:
    -1.) The tires have equal air pressure.
    -2.) The bicycles never leave the contact with the bump.
    -3.) The bicycles have the same mass. The bicycle tires (regardless of size) have the same mass.
    -

    -
    - -
      -
    • - -

      The bicycles with larger wheels have more time to go over the bump. This decreases the magnitude of the force needed to lift the bicycle.

      -
      - - -
    • -
    • - -

      The bicycles with larger wheels always have a smaller vertical displacement regardless of speed.

      -
      - - -
    • -
    • - -

      The bicycles with larger wheels experience a force backward with less magnitude for the same amount of time.

      -
      - - -
    • -
    • - -

      The bicycles with larger wheels experience a force backward with less magnitude for a greater amount of time.

      -
      - - -
    • -
    • - -

      The bicycles with larger wheels have more kinetic energy turned into gravitational potential energy.

      -
      - - -
    • -
    • - -

      The bicycles with larger wheels have more rotational kinetic energy, so the horizontal velocity of the biker changes less.

      -
      - - -
    • -
    - - -
    -
    -
    -
    -
    -
    diff --git a/common/lib/capa/capa/tests/test_files/stringresponse_with_hint.xml b/common/lib/capa/capa/tests/test_files/stringresponse_with_hint.xml deleted file mode 100644 index 86efdf0f18..0000000000 --- a/common/lib/capa/capa/tests/test_files/stringresponse_with_hint.xml +++ /dev/null @@ -1,25 +0,0 @@ - -

    Example: String Response Problem

    -
    -
    - - Which US state has Lansing as its capital? - - - - - - - - - The state capital of Wisconsin is Madison. - - - The state capital of Minnesota is St. Paul. - - - The state you are looking for is also known as the 'Great Lakes State' - - - -
    diff --git a/common/lib/capa/capa/tests/test_files/symbolicresponse.xml b/common/lib/capa/capa/tests/test_files/symbolicresponse.xml deleted file mode 100644 index 4dc2bc9d7b..0000000000 --- a/common/lib/capa/capa/tests/test_files/symbolicresponse.xml +++ /dev/null @@ -1,29 +0,0 @@ - - -

    Example: Symbolic Math Response Problem

    - -

    -A symbolic math response problem presents one or more symbolic math -input fields for input. Correctness of input is evaluated based on -the symbolic properties of the expression entered. The student enters -text, but sees a proper symbolic rendition of the entered formula, in -real time, next to the input box. -

    - -

    This is a correct answer which may be entered below:

    -

    cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]

    - - - Compute [mathjax] U = \exp\left( i \theta \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) [/mathjax] - and give the resulting \(2 \times 2\) matrix.
    - Your input should be typed in as a list of lists, eg [[1,2],[3,4]].
    - [mathjax]U=[/mathjax] - - -
    -
    - -
    -
    diff --git a/common/lib/capa/capa/tests/test_files/truefalse.xml b/common/lib/capa/capa/tests/test_files/truefalse.xml deleted file mode 100644 index 60018f7a2d..0000000000 --- a/common/lib/capa/capa/tests/test_files/truefalse.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - This is foil One. - - - This is foil Two. - - - This is foil Three. - - - This is foil Four. - - - This is foil Five. - - - - diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 18da338b91..33b84d213d 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -16,93 +16,151 @@ from capa.correctmap import CorrectMap from capa.util import convert_files_to_filenames from capa.xqueue_interface import dateformat +class ResponseTest(unittest.TestCase): + """ Base class for tests of capa responses.""" + + xml_factory_class = None -class MultiChoiceTest(unittest.TestCase): - def test_MC_grade(self): - multichoice_file = os.path.dirname(__file__) + "/test_files/multichoice.xml" - test_lcp = lcp.LoncapaProblem(open(multichoice_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'choice_foil3'} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - false_answers = {'1_2_1': 'choice_foil2'} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') + def setUp(self): + if self.xml_factory_class: + self.xml_factory = self.xml_factory_class() - def test_MC_bare_grades(self): - multichoice_file = os.path.dirname(__file__) + "/test_files/multi_bare.xml" - test_lcp = lcp.LoncapaProblem(open(multichoice_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'choice_2'} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - false_answers = {'1_2_1': 'choice_1'} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') + def build_problem(self, **kwargs): + xml = self.xml_factory.build_xml(**kwargs) + return lcp.LoncapaProblem(xml, '1', system=test_system) - def test_TF_grade(self): - truefalse_file = os.path.dirname(__file__) + "/test_files/truefalse.xml" - test_lcp = lcp.LoncapaProblem(open(truefalse_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': ['choice_foil2', 'choice_foil1']} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - false_answers = {'1_2_1': ['choice_foil1']} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') - false_answers = {'1_2_1': ['choice_foil1', 'choice_foil3']} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') - false_answers = {'1_2_1': ['choice_foil3']} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') - false_answers = {'1_2_1': ['choice_foil1', 'choice_foil2', 'choice_foil3']} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') + def assert_grade(self, problem, submission, expected_correctness): + input_dict = {'1_2_1': submission} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_correctness('1_2_1'), expected_correctness) + + def assert_multiple_grade(self, problem, correct_answers, incorrect_answers): + for input_str in correct_answers: + result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1') + self.assertEqual(result, 'correct', + msg="%s should be marked correct" % str(input_str)) + + for input_str in incorrect_answers: + result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1') + self.assertEqual(result, 'incorrect', + msg="%s should be marked incorrect" % str(input_str)) + +class MultiChoiceResponseTest(ResponseTest): + from response_xml_factory import MultipleChoiceResponseXMLFactory + xml_factory_class = MultipleChoiceResponseXMLFactory + + def test_multiple_choice_grade(self): + problem = self.build_problem(choices=[False, True, False]) + + # Ensure that we get the expected grades + self.assert_grade(problem, 'choice_0', 'incorrect') + self.assert_grade(problem, 'choice_1', 'correct') + self.assert_grade(problem, 'choice_2', 'incorrect') + + def test_named_multiple_choice_grade(self): + problem = self.build_problem(choices=[False, True, False], + choice_names=["foil_1", "foil_2", "foil_3"]) + + # Ensure that we get the expected grades + self.assert_grade(problem, 'choice_foil_1', 'incorrect') + self.assert_grade(problem, 'choice_foil_2', 'correct') + self.assert_grade(problem, 'choice_foil_3', 'incorrect') -class ImageResponseTest(unittest.TestCase): - def test_ir_grade(self): - imageresponse_file = os.path.dirname(__file__) + "/test_files/imageresponse.xml" - test_lcp = lcp.LoncapaProblem(open(imageresponse_file).read(), '1', system=test_system) - # testing regions only - correct_answers = { - #regions - '1_2_1': '(490,11)-(556,98)', - '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)', - #testing regions and rectanges - '1_3_1': 'rectangle="(490,11)-(556,98)" \ - regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"', - '1_3_2': 'rectangle="(490,11)-(556,98)" \ - regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"', - '1_3_3': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"', - '1_3_4': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"', - '1_3_5': 'regions="[[[10,10], [20,10], [20, 30]]]"', - '1_3_6': 'regions="[[10,10], [30,30], [15, 15]]"', - '1_3_7': 'regions="[[10,10], [30,30], [10, 30], [30, 10]]"', - } - 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]', +class TrueFalseResponseTest(ResponseTest): + from response_xml_factory import TrueFalseResponseXMLFactory + xml_factory_class = TrueFalseResponseXMLFactory - '1_3_1': '[500,20]', - '1_3_2': '[15,15]', - '1_3_3': '[500,20]', - '1_3_4': '[115,115]', - '1_3_5': '[15,15]', - '1_3_6': '[20,20]', - '1_3_7': '[20,15]', - } + def test_true_false_grade(self): + problem = self.build_problem(choices=[False, True, True]) - # regions - 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') + # Check the results + # Mark correct if and only if ALL (and only) correct choices selected + self.assert_grade(problem, 'choice_0', 'incorrect') + self.assert_grade(problem, 'choice_1', 'incorrect') + self.assert_grade(problem, 'choice_2', 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_1', 'choice_2'], 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_2'], 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_1'], 'incorrect') + self.assert_grade(problem, ['choice_1', 'choice_2'], 'correct') - # regions and rectangles - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_2'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_3'), 'incorrect') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_4'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_5'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_6'), 'incorrect') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_7'), 'correct') + # Invalid choices should be marked incorrect (we have no choice 3) + self.assert_grade(problem, 'choice_3', 'incorrect') + self.assert_grade(problem, 'not_a_choice', 'incorrect') + + def test_named_true_false_grade(self): + problem = self.build_problem(choices=[False, True, True], + choice_names=['foil_1','foil_2','foil_3']) + + # Check the results + # Mark correct if and only if ALL (and only) correct chocies selected + self.assert_grade(problem, 'choice_foil_1', 'incorrect') + self.assert_grade(problem, 'choice_foil_2', 'incorrect') + self.assert_grade(problem, 'choice_foil_3', 'incorrect') + self.assert_grade(problem, ['choice_foil_1', 'choice_foil_2', 'choice_foil_3'], 'incorrect') + self.assert_grade(problem, ['choice_foil_1', 'choice_foil_3'], 'incorrect') + self.assert_grade(problem, ['choice_foil_1', 'choice_foil_2'], 'incorrect') + self.assert_grade(problem, ['choice_foil_2', 'choice_foil_3'], 'correct') + + # Invalid choices should be marked incorrect + self.assert_grade(problem, 'choice_foil_4', 'incorrect') + self.assert_grade(problem, 'not_a_choice', 'incorrect') + +class ImageResponseTest(ResponseTest): + from response_xml_factory import ImageResponseXMLFactory + xml_factory_class = ImageResponseXMLFactory + + def test_rectangle_grade(self): + # Define a rectangle with corners (10,10) and (20,20) + problem = self.build_problem(rectangle="(10,10)-(20,20)") + + # Anything inside the rectangle (and along the borders) is correct + # Everything else is incorrect + correct_inputs = ["[12,19]", "[10,10]", "[20,20]", + "[10,15]", "[20,15]", "[15,10]", "[15,20]"] + incorrect_inputs = ["[4,6]", "[25,15]", "[15,40]", "[15,4]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + + def test_multiple_rectangles_grade(self): + # Define two rectangles + rectangle_str = "(10,10)-(20,20);(100,100)-(200,200)" + + # Expect that only points inside the rectangles are marked correct + problem = self.build_problem(rectangle=rectangle_str) + correct_inputs = ["[12,19]", "[120, 130]"] + incorrect_inputs = ["[4,6]", "[25,15]", "[15,40]", "[15,4]", + "[50,55]", "[300, 14]", "[120, 400]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + + def test_region_grade(self): + # Define a triangular region with corners (0,0), (5,10), and (0, 10) + region_str = "[ [1,1], [5,10], [0,10] ]" + + # Expect that only points inside the triangle are marked correct + problem = self.build_problem(regions=region_str) + correct_inputs = ["[2,4]", "[1,3]"] + incorrect_inputs = ["[0,0]", "[3,5]", "[5,15]", "[30, 12]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + + def test_multiple_regions_grade(self): + # Define multiple regions that the user can select + region_str="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]" + + # Expect that only points inside the regions are marked correct + problem = self.build_problem(regions=region_str) + correct_inputs = ["[15,12]", "[110,112]"] + incorrect_inputs = ["[0,0]", "[600,300]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + + def test_region_and_rectangle_grade(self): + rectangle_str = "(100,100)-(200,200)" + region_str="[[10,10], [20,10], [20, 30]]" + + # Expect that only points inside the rectangle or region are marked correct + problem = self.build_problem(regions=region_str, rectangle=rectangle_str) + correct_inputs = ["[13,12]", "[110,112]"] + incorrect_inputs = ["[0,0]", "[600,300]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) class SymbolicResponseTest(unittest.TestCase): @@ -195,60 +253,165 @@ class SymbolicResponseTest(unittest.TestCase): self.assertEquals(test_lcp.grade_answers(wrong_answers).get_correctness('1_2_1'), 'incorrect') -class OptionResponseTest(unittest.TestCase): - ''' - Run this with +class OptionResponseTest(ResponseTest): + from response_xml_factory import OptionResponseXMLFactory + xml_factory_class = OptionResponseXMLFactory - python manage.py test courseware.OptionResponseTest - ''' - def test_or_grade(self): - optionresponse_file = os.path.dirname(__file__) + "/test_files/optionresponse.xml" - test_lcp = lcp.LoncapaProblem(open(optionresponse_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'True', - '1_2_2': 'False'} - test_answers = {'1_2_1': 'True', - '1_2_2': 'True', - } - 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') + def test_grade(self): + problem = self.build_problem(options=["first", "second", "third"], + correct_option="second") + + # Assert that we get the expected grades + self.assert_grade(problem, "first", "incorrect") + self.assert_grade(problem, "second", "correct") + self.assert_grade(problem, "third", "incorrect") + + # Options not in the list should be marked incorrect + self.assert_grade(problem, "invalid_option", "incorrect") -class FormulaResponseWithHintTest(unittest.TestCase): - ''' - Test Formula response problem with a hint - This problem also uses calc. - ''' - def test_or_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/formularesponse_with_hint.xml" - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': '2.5*x-5.0'} - test_answers = {'1_2_1': '0.4*x-5.0'} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - cmap = test_lcp.grade_answers(test_answers) - self.assertEquals(cmap.get_correctness('1_2_1'), 'incorrect') - self.assertTrue('You have inverted' in cmap.get_hint('1_2_1')) +class FormulaResponseTest(ResponseTest): + from response_xml_factory import FormulaResponseXMLFactory + xml_factory_class = FormulaResponseXMLFactory + + def test_grade(self): + # Sample variables x and y in the range [-10, 10] + sample_dict = {'x': (-10, 10), 'y': (-10, 10)} + + # The expected solution is numerically equivalent to x+2y + problem = self.build_problem(sample_dict=sample_dict, + num_samples=10, + tolerance=0.01, + answer="x+2*y") + + # Expect an equivalent formula to be marked correct + # 2x - x + y + y = x + 2y + input_formula = "2*x - x + y + y" + self.assert_grade(problem, input_formula, "correct") + + # Expect an incorrect formula to be marked incorrect + # x + y != x + 2y + input_formula = "x + y" + self.assert_grade(problem, input_formula, "incorrect") + + def test_hint(self): + # Sample variables x and y in the range [-10, 10] + sample_dict = {'x': (-10, 10), 'y': (-10,10) } + + # Give a hint if the user leaves off the coefficient + # or leaves out x + hints = [('x + 3*y', 'y_coefficient', 'Check the coefficient of y'), + ('2*y', 'missing_x', 'Try including the variable x')] -class StringResponseWithHintTest(unittest.TestCase): - ''' - Test String response problem with a hint - ''' - def test_or_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/stringresponse_with_hint.xml" - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'Michigan'} - test_answers = {'1_2_1': 'Minnesota'} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - cmap = test_lcp.grade_answers(test_answers) - self.assertEquals(cmap.get_correctness('1_2_1'), 'incorrect') - self.assertTrue('St. Paul' in cmap.get_hint('1_2_1')) + # The expected solution is numerically equivalent to x+2y + problem = self.build_problem(sample_dict=sample_dict, + num_samples=10, + tolerance=0.01, + answer="x+2*y", + hints=hints) + + # Expect to receive a hint if we add an extra y + input_dict = {'1_2_1': "x + 2*y + y"} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), + 'Check the coefficient of y') + + # Expect to receive a hint if we leave out x + input_dict = {'1_2_1': "2*y"} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), + 'Try including the variable x') -class CodeResponseTest(unittest.TestCase): - ''' - Test CodeResponse - TODO: Add tests for external grader messages - ''' + def test_script(self): + # Calculate the answer using a script + script = "calculated_ans = 'x+x'" + + # Sample x in the range [-10,10] + sample_dict = {'x': (-10, 10)} + + # The expected solution is numerically equivalent to 2*x + problem = self.build_problem(sample_dict=sample_dict, + num_samples=10, + tolerance=0.01, + answer="$calculated_ans", + script=script) + + # Expect that the inputs are graded correctly + self.assert_grade(problem, '2*x', 'correct') + self.assert_grade(problem, '3*x', 'incorrect') + + +class StringResponseTest(ResponseTest): + from response_xml_factory import StringResponseXMLFactory + xml_factory_class = StringResponseXMLFactory + + + def test_case_sensitive(self): + problem = self.build_problem(answer="Second", case_sensitive=True) + + # Exact string should be correct + self.assert_grade(problem, "Second", "correct") + + # Other strings and the lowercase version of the string are incorrect + self.assert_grade(problem, "Other String", "incorrect") + self.assert_grade(problem, "second", "incorrect") + + def test_case_insensitive(self): + problem = self.build_problem(answer="Second", case_sensitive=False) + + # Both versions of the string should be allowed, regardless + # of capitalization + self.assert_grade(problem, "Second", "correct") + self.assert_grade(problem, "second", "correct") + + # Other strings are not allowed + self.assert_grade(problem, "Other String", "incorrect") + + def test_hints(self): + hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"), + ("minnesota", "minn", "The state capital of Minnesota is St. Paul")] + + problem = self.build_problem(answer="Michigan", + case_sensitive=False, + hints=hints) + + # We should get a hint for Wisconsin + input_dict = {'1_2_1': 'Wisconsin'} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), + "The state capital of Wisconsin is Madison") + + # We should get a hint for Minnesota + input_dict = {'1_2_1': 'Minnesota'} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), + "The state capital of Minnesota is St. Paul") + + # We should NOT get a hint for Michigan (the correct answer) + input_dict = {'1_2_1': 'Michigan'} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), "") + + # We should NOT get a hint for any other string + input_dict = {'1_2_1': 'California'} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), "") + +class CodeResponseTest(ResponseTest): + from response_xml_factory import CodeResponseXMLFactory + xml_factory_class = CodeResponseXMLFactory + + def setUp(self): + super(CodeResponseTest, self).setUp() + + grader_payload = json.dumps({"grader": "ps04/grade_square.py"}) + self.problem = self.build_problem(initial_display="def square(x):", + answer_display="answer", + grader_payload=grader_payload, + num_responses=2) + @staticmethod def make_queuestate(key, time): timestr = datetime.strftime(time, dateformat) @@ -258,171 +421,354 @@ class CodeResponseTest(unittest.TestCase): """ Simple test of whether LoncapaProblem knows when it's been queued """ - problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") - with open(problem_file) as input_file: - test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=test_system) - answer_ids = sorted(test_lcp.get_question_answers()) + answer_ids = sorted(self.problem.get_question_answers()) - # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state - cmap = CorrectMap() - for answer_id in answer_ids: - cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) - test_lcp.correct_map.update(cmap) + # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state + cmap = CorrectMap() + for answer_id in answer_ids: + cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) + self.problem.correct_map.update(cmap) - self.assertEquals(test_lcp.is_queued(), False) + self.assertEquals(self.problem.is_queued(), False) - # Now we queue the LCP - cmap = CorrectMap() - for i, answer_id in enumerate(answer_ids): - queuestate = CodeResponseTest.make_queuestate(i, datetime.now()) - cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) - test_lcp.correct_map.update(cmap) + # Now we queue the LCP + cmap = CorrectMap() + for i, answer_id in enumerate(answer_ids): + queuestate = CodeResponseTest.make_queuestate(i, datetime.now()) + cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) + self.problem.correct_map.update(cmap) - self.assertEquals(test_lcp.is_queued(), True) + self.assertEquals(self.problem.is_queued(), True) def test_update_score(self): ''' Test whether LoncapaProblem.update_score can deliver queued result to the right subproblem ''' - problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") - with open(problem_file) as input_file: - test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=test_system) + answer_ids = sorted(self.problem.get_question_answers()) - answer_ids = sorted(test_lcp.get_question_answers()) + # CodeResponse requires internal CorrectMap state. Build it now in the queued state + old_cmap = CorrectMap() + for i, answer_id in enumerate(answer_ids): + queuekey = 1000 + i + queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now()) + old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) - # CodeResponse requires internal CorrectMap state. Build it now in the queued state - old_cmap = CorrectMap() + # Message format common to external graders + grader_msg = 'MESSAGE' # Must be valid XML + correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg}) + incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg}) + + xserver_msgs = {'correct': correct_score_msg, + 'incorrect': incorrect_score_msg, } + + # Incorrect queuekey, state should not be updated + for correctness in ['correct', 'incorrect']: + self.problem.correct_map = CorrectMap() + self.problem.correct_map.update(old_cmap) # Deep copy + + self.problem.update_score(xserver_msgs[correctness], queuekey=0) + self.assertEquals(self.problem.correct_map.get_dict(), old_cmap.get_dict()) # Deep comparison + + for answer_id in answer_ids: + self.assertTrue(self.problem.correct_map.is_queued(answer_id)) # Should be still queued, since message undelivered + + # Correct queuekey, state should be updated + for correctness in ['correct', 'incorrect']: for i, answer_id in enumerate(answer_ids): - queuekey = 1000 + i - queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now()) - old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) + self.problem.correct_map = CorrectMap() + self.problem.correct_map.update(old_cmap) - # Message format common to external graders - grader_msg = 'MESSAGE' # Must be valid XML - correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg}) - incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg}) + new_cmap = CorrectMap() + new_cmap.update(old_cmap) + npoints = 1 if correctness == 'correct' else 0 + new_cmap.set(answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None) - xserver_msgs = {'correct': correct_score_msg, - 'incorrect': incorrect_score_msg, } + self.problem.update_score(xserver_msgs[correctness], queuekey=1000 + i) + self.assertEquals(self.problem.correct_map.get_dict(), new_cmap.get_dict()) - # Incorrect queuekey, state should not be updated - for correctness in ['correct', 'incorrect']: - test_lcp.correct_map = CorrectMap() - test_lcp.correct_map.update(old_cmap) # Deep copy - - test_lcp.update_score(xserver_msgs[correctness], queuekey=0) - self.assertEquals(test_lcp.correct_map.get_dict(), old_cmap.get_dict()) # Deep comparison - - for answer_id in answer_ids: - self.assertTrue(test_lcp.correct_map.is_queued(answer_id)) # Should be still queued, since message undelivered - - # Correct queuekey, state should be updated - for correctness in ['correct', 'incorrect']: - for i, answer_id in enumerate(answer_ids): - test_lcp.correct_map = CorrectMap() - test_lcp.correct_map.update(old_cmap) - - new_cmap = CorrectMap() - new_cmap.update(old_cmap) - npoints = 1 if correctness == 'correct' else 0 - new_cmap.set(answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None) - - test_lcp.update_score(xserver_msgs[correctness], queuekey=1000 + i) - self.assertEquals(test_lcp.correct_map.get_dict(), new_cmap.get_dict()) - - for j, test_id in enumerate(answer_ids): - if j == i: - self.assertFalse(test_lcp.correct_map.is_queued(test_id)) # Should be dequeued, message delivered - else: - self.assertTrue(test_lcp.correct_map.is_queued(test_id)) # Should be queued, message undelivered + for j, test_id in enumerate(answer_ids): + if j == i: + self.assertFalse(self.problem.correct_map.is_queued(test_id)) # Should be dequeued, message delivered + else: + self.assertTrue(self.problem.correct_map.is_queued(test_id)) # Should be queued, message undelivered def test_recentmost_queuetime(self): ''' Test whether the LoncapaProblem knows about the time of queue requests ''' - problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") - with open(problem_file) as input_file: - test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=test_system) + answer_ids = sorted(self.problem.get_question_answers()) - answer_ids = sorted(test_lcp.get_question_answers()) + # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state + cmap = CorrectMap() + for answer_id in answer_ids: + cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) + self.problem.correct_map.update(cmap) - # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state - cmap = CorrectMap() - for answer_id in answer_ids: - cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) - test_lcp.correct_map.update(cmap) + self.assertEquals(self.problem.get_recentmost_queuetime(), None) - self.assertEquals(test_lcp.get_recentmost_queuetime(), None) + # CodeResponse requires internal CorrectMap state. Build it now in the queued state + cmap = CorrectMap() + for i, answer_id in enumerate(answer_ids): + queuekey = 1000 + i + latest_timestamp = datetime.now() + queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp) + cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate)) + self.problem.correct_map.update(cmap) - # CodeResponse requires internal CorrectMap state. Build it now in the queued state - cmap = CorrectMap() - for i, answer_id in enumerate(answer_ids): - queuekey = 1000 + i - latest_timestamp = datetime.now() - queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp) - cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate)) - test_lcp.correct_map.update(cmap) + # Queue state only tracks up to second + latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat) - # Queue state only tracks up to second - latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat) + self.assertEquals(self.problem.get_recentmost_queuetime(), latest_timestamp) - self.assertEquals(test_lcp.get_recentmost_queuetime(), latest_timestamp) + def test_convert_files_to_filenames(self): + ''' + Test whether file objects are converted to filenames without altering other structures + ''' + problem_file = os.path.join(os.path.dirname(__file__), "test_files/filename_convert_test.txt") + with open(problem_file) as fp: + answers_with_file = {'1_2_1': 'String-based answer', + '1_3_1': ['answer1', 'answer2', 'answer3'], + '1_4_1': [fp, fp]} + answers_converted = convert_files_to_filenames(answers_with_file) + self.assertEquals(answers_converted['1_2_1'], 'String-based answer') + self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3']) + self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name]) - def test_convert_files_to_filenames(self): - ''' - Test whether file objects are converted to filenames without altering other structures - ''' - problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") - with open(problem_file) as fp: - answers_with_file = {'1_2_1': 'String-based answer', - '1_3_1': ['answer1', 'answer2', 'answer3'], - '1_4_1': [fp, fp]} - answers_converted = convert_files_to_filenames(answers_with_file) - self.assertEquals(answers_converted['1_2_1'], 'String-based answer') - self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3']) - self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name]) +class ChoiceResponseTest(ResponseTest): + from response_xml_factory import ChoiceResponseXMLFactory + xml_factory_class = ChoiceResponseXMLFactory + + def test_radio_group_grade(self): + problem = self.build_problem(choice_type='radio', + choices=[False, True, False]) + + # Check that we get the expected results + self.assert_grade(problem, 'choice_0', 'incorrect') + self.assert_grade(problem, 'choice_1', 'correct') + self.assert_grade(problem, 'choice_2', 'incorrect') + + # No choice 3 exists --> mark incorrect + self.assert_grade(problem, 'choice_3', 'incorrect') -class ChoiceResponseTest(unittest.TestCase): + def test_checkbox_group_grade(self): + problem = self.build_problem(choice_type='checkbox', + choices=[False, True, True]) - def test_cr_rb_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/choiceresponse_radio.xml" - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'choice_2', - '1_3_1': ['choice_2', 'choice_3']} - test_answers = {'1_2_1': 'choice_2', - '1_3_1': 'choice_2', - } - 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_3_1'), 'incorrect') + # Check that we get the expected results + # (correct if and only if BOTH correct choices chosen) + self.assert_grade(problem, ['choice_1', 'choice_2'], 'correct') + self.assert_grade(problem, 'choice_1', 'incorrect') + self.assert_grade(problem, 'choice_2', 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_1'], 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_2'], 'incorrect') - def test_cr_cb_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/choiceresponse_checkbox.xml" - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'choice_2', - '1_3_1': ['choice_2', 'choice_3'], - '1_4_1': ['choice_2', 'choice_3']} - test_answers = {'1_2_1': 'choice_2', - '1_3_1': 'choice_2', - '1_4_1': ['choice_2', 'choice_3'], - } - 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_3_1'), 'incorrect') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_4_1'), 'correct') + # No choice 3 exists --> mark incorrect + self.assert_grade(problem, 'choice_3', 'incorrect') -class JavascriptResponseTest(unittest.TestCase): +class JavascriptResponseTest(ResponseTest): + from response_xml_factory import JavascriptResponseXMLFactory + xml_factory_class = JavascriptResponseXMLFactory - def test_jr_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/javascriptresponse.xml" + def test_grade(self): + # Compile coffee files into javascript used by the response coffee_file_path = os.path.dirname(__file__) + "/test_files/js/*.coffee" os.system("coffee -c %s" % (coffee_file_path)) - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': json.dumps({0: 4})} - incorrect_answers = {'1_2_1': json.dumps({0: 5})} - self.assertEquals(test_lcp.grade_answers(incorrect_answers).get_correctness('1_2_1'), 'incorrect') - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') + problem = self.build_problem(generator_src="test_problem_generator.js", + grader_src="test_problem_grader.js", + display_class="TestProblemDisplay", + display_src="test_problem_display.js", + param_dict={'value': '4'}) + + # Test that we get graded correctly + self.assert_grade(problem, json.dumps({0:4}), "correct") + self.assert_grade(problem, json.dumps({0:5}), "incorrect") + +class NumericalResponseTest(ResponseTest): + from response_xml_factory import NumericalResponseXMLFactory + xml_factory_class = NumericalResponseXMLFactory + + def test_grade_exact(self): + problem = self.build_problem(question_text="What is 2 + 2?", + explanation="The answer is 4", + answer=4) + correct_responses = ["4", "4.0", "4.00"] + incorrect_responses = ["", "3.9", "4.1", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + + def test_grade_decimal_tolerance(self): + problem = self.build_problem(question_text="What is 2 + 2 approximately?", + explanation="The answer is 4", + answer=4, + tolerance=0.1) + correct_responses = ["4.0", "4.00", "4.09", "3.91"] + incorrect_responses = ["", "4.11", "3.89", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + def test_grade_percent_tolerance(self): + problem = self.build_problem(question_text="What is 2 + 2 approximately?", + explanation="The answer is 4", + answer=4, + tolerance="10%") + correct_responses = ["4.0", "4.3", "3.7", "4.30", "3.70"] + incorrect_responses = ["", "4.5", "3.5", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + def test_grade_with_script(self): + script_text = "computed_response = math.sqrt(4)" + problem = self.build_problem(question_text="What is sqrt(4)?", + explanation="The answer is 2", + answer="$computed_response", + script=script_text) + correct_responses = ["2", "2.0"] + incorrect_responses = ["", "2.01", "1.99", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + def test_grade_with_script_and_tolerance(self): + script_text = "computed_response = math.sqrt(4)" + problem = self.build_problem(question_text="What is sqrt(4)?", + explanation="The answer is 2", + answer="$computed_response", + tolerance="0.1", + script=script_text) + correct_responses = ["2", "2.0", "2.05", "1.95"] + incorrect_responses = ["", "2.11", "1.89", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + +class CustomResponseTest(ResponseTest): + from response_xml_factory import CustomResponseXMLFactory + xml_factory_class = CustomResponseXMLFactory + + def test_inline_code(self): + + # For inline code, we directly modify global context variables + # 'answers' is a list of answers provided to us + # 'correct' is a list we fill in with True/False + # 'expect' is given to us (if provided in the XML) + inline_script = """correct[0] = 'correct' if (answers['1_2_1'] == expect) else 'incorrect'""" + problem = self.build_problem(answer=inline_script, expect="42") + + # Check results + self.assert_grade(problem, '42', 'correct') + self.assert_grade(problem, '0', 'incorrect') + + def test_inline_message(self): + + # Inline code can update the global messages list + # to pass messages to the CorrectMap for a particular input + inline_script = """messages[0] = "Test Message" """ + problem = self.build_problem(answer=inline_script) + + input_dict = {'1_2_1': '0'} + msg = problem.grade_answers(input_dict).get_msg('1_2_1') + self.assertEqual(msg, "Test Message") + + def test_function_code(self): + + # For function code, we pass in three arguments: + # + # 'expect' is the expect attribute of the + # + # 'answer_given' is the answer the student gave (if there is just one input) + # or an ordered list of answers (if there are multiple inputs) + # + # 'student_answers' is a dictionary of answers by input ID + # + # + # The function should return a dict of the form + # { 'ok': BOOL, 'msg': STRING } + # + script = """def check_func(expect, answer_given, student_answers): + return {'ok': answer_given == expect, 'msg': 'Message text'}""" + + problem = self.build_problem(script=script, cfn="check_func", expect="42") + + # Correct answer + input_dict = {'1_2_1': '42'} + correct_map = problem.grade_answers(input_dict) + + correctness = correct_map.get_correctness('1_2_1') + msg = correct_map.get_msg('1_2_1') + + self.assertEqual(correctness, 'correct') + self.assertEqual(msg, "Message text\n") + + # Incorrect answer + input_dict = {'1_2_1': '0'} + correct_map = problem.grade_answers(input_dict) + + correctness = correct_map.get_correctness('1_2_1') + msg = correct_map.get_msg('1_2_1') + + self.assertEqual(correctness, 'incorrect') + self.assertEqual(msg, "Message text\n") + + def test_multiple_inputs(self): + # When given multiple inputs, the 'answer_given' argument + # to the check_func() is a list of inputs + # The sample script below marks the problem as correct + # if and only if it receives answer_given=[1,2,3] + # (or string values ['1','2','3']) + script = """def check_func(expect, answer_given, student_answers): + check1 = (int(answer_given[0]) == 1) + check2 = (int(answer_given[1]) == 2) + check3 = (int(answer_given[2]) == 3) + return {'ok': (check1 and check2 and check3), 'msg': 'Message text'}""" + + problem = self.build_problem(script=script, + cfn="check_func", num_inputs=3) + + # Grade the inputs (one input incorrect) + input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3' } + correct_map = problem.grade_answers(input_dict) + + # Everything marked incorrect + self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect') + self.assertEqual(correct_map.get_correctness('1_2_2'), 'incorrect') + self.assertEqual(correct_map.get_correctness('1_2_3'), 'incorrect') + + # Grade the inputs (everything correct) + input_dict = {'1_2_1': '1', '1_2_2': '2', '1_2_3': '3' } + correct_map = problem.grade_answers(input_dict) + + # Everything marked incorrect + self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') + self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct') + self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct') + + +class SchematicResponseTest(ResponseTest): + from response_xml_factory import SchematicResponseXMLFactory + xml_factory_class = SchematicResponseXMLFactory + + def test_grade(self): + + # Most of the schematic-specific work is handled elsewhere + # (in client-side JavaScript) + # The is responsible only for executing the + # Python code in with *submission* (list) + # in the global context. + + # To test that the context is set up correctly, + # we create a script that sets *correct* to true + # if and only if we find the *submission* (list) + script="correct = ['correct' if 'test' in submission[0] else 'incorrect']" + problem = self.build_problem(answer=script) + + # The actual dictionary would contain schematic information + # sent from the JavaScript simulation + submission_dict = {'test': 'test'} + input_dict = { '1_2_1': json.dumps(submission_dict) } + correct_map = problem.grade_answers(input_dict) + + # Expect that the problem is graded as true + # (That is, our script verifies that the context + # is what we expect) + self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') diff --git a/common/lib/capa/capa/verifiers/draganddrop.py b/common/lib/capa/capa/verifiers/draganddrop.py index eb91208923..239ff2b9a4 100644 --- a/common/lib/capa/capa/verifiers/draganddrop.py +++ b/common/lib/capa/capa/verifiers/draganddrop.py @@ -111,7 +111,7 @@ class DragAndDrop(object): Returns: bool. ''' for draggable in self.excess_draggables: - if not self.excess_draggables[draggable]: + if self.excess_draggables[draggable]: return False # user answer has more draggables than correct answer # Number of draggables in user_groups may be differ that in @@ -304,8 +304,13 @@ class DragAndDrop(object): user_answer = json.loads(user_answer) - # check if we have draggables that are not in correct answer: - self.excess_draggables = {} + # This dictionary will hold a key for each draggable the user placed on + # the image. The value is True if that draggable is not mentioned in any + # correct_answer entries. If the draggable is mentioned in at least one + # correct_answer entry, the value is False. + # default to consider every user answer excess until proven otherwise. + self.excess_draggables = dict((users_draggable.keys()[0],True) + for users_draggable in user_answer['draggables']) # create identical data structures from user answer and correct answer for i in xrange(0, len(correct_answer)): @@ -322,11 +327,8 @@ class DragAndDrop(object): self.user_groups[groupname].append(draggable_name) self.user_positions[groupname]['user'].append( draggable_dict[draggable_name]) - self.excess_draggables[draggable_name] = True - else: - self.excess_draggables[draggable_name] = \ - self.excess_draggables.get(draggable_name, False) - + # proved that this is not excess + self.excess_draggables[draggable_name] = False def grade(user_input, correct_answer): """ Creates DragAndDrop instance from user_input and correct_answer and diff --git a/common/lib/capa/capa/verifiers/tests_draganddrop.py b/common/lib/capa/capa/verifiers/tests_draganddrop.py index 9b1b15ce0c..bcd024fa89 100644 --- a/common/lib/capa/capa/verifiers/tests_draganddrop.py +++ b/common/lib/capa/capa/verifiers/tests_draganddrop.py @@ -46,6 +46,18 @@ class Test_DragAndDrop_Grade(unittest.TestCase): correct_answer = {'1': 't1', 'name_with_icon': 't2'} self.assertTrue(draganddrop.grade(user_input, correct_answer)) + def test_expect_no_actions_wrong(self): + user_input = '{"draggables": [{"1": "t1"}, \ + {"name_with_icon": "t2"}]}' + correct_answer = [] + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_expect_no_actions_right(self): + user_input = '{"draggables": []}' + correct_answer = [] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_targets_false(self): user_input = '{"draggables": [{"1": "t1"}, \ {"name_with_icon": "t2"}]}' diff --git a/common/lib/tempdir.py b/common/lib/tempdir.py new file mode 100644 index 0000000000..0acd92ba33 --- /dev/null +++ b/common/lib/tempdir.py @@ -0,0 +1,17 @@ +"""Make temporary directories nicely.""" + +import atexit +import os.path +import shutil +import tempfile + +def mkdtemp_clean(suffix="", prefix="tmp", dir=None): + """Just like mkdtemp, but the directory will be deleted when the process ends.""" + the_dir = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir) + atexit.register(cleanup_tempdir, the_dir) + return the_dir + +def cleanup_tempdir(the_dir): + """Called on process exit to remove a temp directory.""" + if os.path.exists(the_dir): + shutil.rmtree(the_dir) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 0a9c05f3ec..ec369420cd 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -37,6 +37,7 @@ setup( "timelimit = xmodule.timelimit_module:TimeLimitDescriptor", "vertical = xmodule.vertical_module:VerticalDescriptor", "video = xmodule.video_module:VideoDescriptor", + "videoalpha = xmodule.videoalpha_module:VideoAlphaDescriptor", "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", "videosequence = xmodule.seq_module:SequenceDescriptor", "discussion = xmodule.discussion_module:DiscussionDescriptor", @@ -44,7 +45,8 @@ setup( "static_tab = xmodule.html_module:StaticTabDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor", "about = xmodule.html_module:AboutDescriptor", - "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor" - ] + "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", + "foldit = xmodule.foldit_module:FolditDescriptor", + ] } ) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d806ec7913..a115a54376 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -429,6 +429,11 @@ class CapaModule(XModule): # used by conditional module return self.attempts > 0 + def is_correct(self): + """True if full points""" + d = self.get_score() + return d['score'] == d['total'] + def answer_available(self): ''' Is the user allowed to see an answer? @@ -449,6 +454,9 @@ class CapaModule(XModule): return self.lcp.done elif self.show_answer == 'closed': return self.closed() + elif self.show_answer == 'finished': + return self.closed() or self.is_correct() + elif self.show_answer == 'past_due': return self.is_past_due() elif self.show_answer == 'always': @@ -703,15 +711,15 @@ class CapaDescriptor(RawDescriptor): def get_context(self): _context = RawDescriptor.get_context(self) - _context.update({'markdown': self.metadata.get('markdown', '')}) + _context.update({'markdown': self.metadata.get('markdown', ''), + 'enable_markdown' : 'markdown' in self.metadata}) return _context @property def editable_metadata_fields(self): - """Remove metadata from the editable fields since it has its own editor""" - subset = super(CapaDescriptor, self).editable_metadata_fields - if 'markdown' in subset: - subset.remove('markdown') + """Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user.""" + subset = [field for field in super(CapaDescriptor,self).editable_metadata_fields + if field not in ['markdown', 'empty']] return subset diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 2da15a4086..ee69d925d0 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -1,25 +1,13 @@ -import copy -from fs.errors import ResourceNotFoundError -import itertools import json import logging from lxml import etree -from lxml.html import rewrite_links -from path import path -import os -import sys 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 progress import Progress -from .stringify import stringify_children from .x_module import XModule from .xml_module import XmlDescriptor -from xmodule.modulestore import Location -from combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor +from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor log = logging.getLogger("mitx.courseware") @@ -120,11 +108,13 @@ class CombinedOpenEndedModule(XModule): instance_state = {} self.version = self.metadata.get('version', DEFAULT_VERSION) + version_error_string = "Version of combined open ended module {0} is not correct. Going with version {1}" if not isinstance(self.version, basestring): try: self.version = str(self.version) except: - log.error("Version {0} is not correct. Going with version {1}".format(self.version, DEFAULT_VERSION)) + #This is a dev_facing_error + log.info(version_error_string.format(self.version, DEFAULT_VERSION)) self.version = DEFAULT_VERSION versions = [i[0] for i in VERSION_TUPLES] @@ -134,7 +124,8 @@ class CombinedOpenEndedModule(XModule): try: version_index = versions.index(self.version) except: - log.error("Version {0} is not correct. Going with version {1}".format(self.version, DEFAULT_VERSION)) + #This is a dev_facing_error + log.error(version_error_string.format(self.version, DEFAULT_VERSION)) self.version = DEFAULT_VERSION version_index = versions.index(self.version) @@ -217,4 +208,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor): for child in ['task']: add_child(child) - return elt \ No newline at end of file + return elt diff --git a/common/lib/xmodule/xmodule/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/combined_open_ended_rubric.py deleted file mode 100644 index 689103a86a..0000000000 --- a/common/lib/xmodule/xmodule/combined_open_ended_rubric.py +++ /dev/null @@ -1,169 +0,0 @@ -import logging -from lxml import etree - -log = logging.getLogger(__name__) - - -class RubricParsingError(Exception): - def __init__(self, msg): - self.msg = msg - - -class CombinedOpenEndedRubric(object): - - def __init__ (self, system, view_only = False): - self.has_score = False - self.view_only = view_only - self.system = system - - def render_rubric(self, rubric_xml): - ''' - render_rubric: takes in an xml string and outputs the corresponding - html for that xml, given the type of rubric we're generating - Input: - rubric_xml: an string that has not been parsed into xml that - represents this particular rubric - Output: - html: the html that corresponds to the xml given - ''' - success = False - try: - rubric_categories = self.extract_categories(rubric_xml) - max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories) - max_score = max(max_scores) - html = self.system.render_template('open_ended_rubric.html', - {'categories': rubric_categories, - 'has_score': self.has_score, - 'view_only': self.view_only, - 'max_score': max_score}) - success = True - except: - error_message = "[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml) - log.error(error_message) - raise RubricParsingError(error_message) - return success, html - - def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed, max_score): - success, rubric_feedback = self.render_rubric(rubric_string) - if not success: - error_message = "Could not parse rubric : {0} for location {1}".format(rubric_string, location.url()) - log.error(error_message) - raise RubricParsingError(error_message) - - rubric_categories = self.extract_categories(rubric_string) - total = 0 - for category in rubric_categories: - total = total + len(category['options']) - 1 - if len(category['options']) > (max_score_allowed + 1): - error_message = "Number of score points in rubric {0} higher than the max allowed, which is {1}".format( - len(category['options']), max_score_allowed) - log.error(error_message) - raise RubricParsingError(error_message) - - if total != max_score: - error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}".format( - max_score, location, total) - log.error(error_msg) - raise RubricParsingError(error_msg) - - def extract_categories(self, element): - ''' - Contstruct a list of categories such that the structure looks like: - [ { category: "Category 1 Name", - options: [{text: "Option 1 Name", points: 0}, {text:"Option 2 Name", points: 5}] - }, - { category: "Category 2 Name", - options: [{text: "Option 1 Name", points: 0}, - {text: "Option 2 Name", points: 1}, - {text: "Option 3 Name", points: 2]}] - - ''' - if isinstance(element, basestring): - element = etree.fromstring(element) - categories = [] - for category in element: - if category.tag != 'category': - raise RubricParsingError("[extract_categories] Expected a tag: got {0} instead".format(category.tag)) - else: - categories.append(self.extract_category(category)) - return categories - - - def extract_category(self, category): - ''' - construct an individual category - {category: "Category 1 Name", - options: [{text: "Option 1 text", points: 1}, - {text: "Option 2 text", points: 2}]} - - all sorting and auto-point generation occurs in this function - ''' - descriptionxml = category[0] - optionsxml = category[1:] - scorexml = category[1] - score = None - if scorexml.tag == 'score': - score_text = scorexml.text - optionsxml = category[2:] - score = int(score_text) - self.has_score = True - # if we are missing the score tag and we are expecting one - elif self.has_score: - raise RubricParsingError("[extract_category] Category {0} is missing a score".format(descriptionxml.text)) - - - # parse description - if descriptionxml.tag != 'description': - raise RubricParsingError("[extract_category]: expected description tag, got {0} instead".format(descriptionxml.tag)) - - description = descriptionxml.text - - cur_points = 0 - options = [] - autonumbering = True - # parse options - for option in optionsxml: - if option.tag != 'option': - raise RubricParsingError("[extract_category]: expected option tag, got {0} instead".format(option.tag)) - else: - pointstr = option.get("points") - if pointstr: - autonumbering = False - # try to parse this into an int - try: - points = int(pointstr) - except ValueError: - raise RubricParsingError("[extract_category]: expected points to have int, got {0} instead".format(pointstr)) - elif autonumbering: - # use the generated one if we're in the right mode - points = cur_points - cur_points = cur_points + 1 - else: - raise Exception("[extract_category]: missing points attribute. Cannot continue to auto-create points values after a points value is explicitly defined.") - - selected = score == points - optiontext = option.text - options.append({'text': option.text, 'points': points, 'selected': selected}) - - # sort and check for duplicates - options = sorted(options, key=lambda option: option['points']) - CombinedOpenEndedRubric.validate_options(options) - - return {'description': description, 'options': options} - - - @staticmethod - def validate_options(options): - ''' - Validates a set of options. This can and should be extended to filter out other bad edge cases - ''' - if len(options) == 0: - raise RubricParsingError("[extract_category]: no options associated with this category") - if len(options) == 1: - return - prev = options[0]['points'] - for option in options[1:]: - if prev == option['points']: - raise RubricParsingError("[extract_category]: found duplicate point values between two different options") - else: - prev = option['points'] diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index a4ad548ae8..2ed780fcae 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -352,6 +352,13 @@ class CourseDescriptor(SequenceDescriptor): """ return self.metadata.get('tabs') + @property + def pdf_textbooks(self): + """ + Return the pdf_textbooks config, as a python object, or None if not specified. + """ + return self.metadata.get('pdf_textbooks') + @tabs.setter def tabs(self, value): self.metadata['tabs'] = value @@ -371,6 +378,28 @@ class CourseDescriptor(SequenceDescriptor): return bool(config.get("cohorted")) + @property + def auto_cohort(self): + """ + Return whether the course is auto-cohorted. + """ + if not self.is_cohorted: + return False + + return bool(self.metadata.get("cohort_config", {}).get( + "auto_cohort", False)) + + @property + def auto_cohort_groups(self): + """ + Return the list of groups to put students into. Returns [] if not + specified. Returns specified list even if is_cohorted and/or auto_cohort are + false. + """ + return self.metadata.get("cohort_config", {}).get( + "auto_cohort_groups", []) + + @property def top_level_discussion_topic_ids(self): """ @@ -446,7 +475,7 @@ class CourseDescriptor(SequenceDescriptor): # utility function to get datetime objects for dates used to # compute the is_new flag and the sorting_score def to_datetime(timestamp): - return datetime.fromtimestamp(time.mktime(timestamp)) + return datetime(*timestamp[:6]) def get_date(field): timetuple = self._try_parse_time(field) @@ -707,7 +736,7 @@ class CourseDescriptor(SequenceDescriptor): def get_test_center_exam(self, exam_series_code): exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code] return exams[0] if len(exams) == 1 else None - + @property def title(self): return self.display_name diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index d40bdb556e..5b9a62a9fd 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -40,7 +40,7 @@ section.problem { @include clearfix; label.choicegroup_correct{ - text:after{ + &:after{ content: url('../images/correct-icon.png'); } } diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 8d921f828b..20700ab092 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -24,14 +24,11 @@ section.combined-open-ended { @include clearfix; .status-container { - float:right; - width:40%; + padding-bottom: 5px; } .item-container { - float:left; - width: 53%; - padding-bottom: 50px; + padding-bottom: 10px; } .result-container @@ -46,14 +43,26 @@ section.combined-open-ended { } } +section.legend-container { + .legenditem { + background-color : #d4d4d4; + font-size: .9em; + padding: 2px; + display: inline; + width: 20%; + } + margin-bottom: 5px; +} + section.combined-open-ended-status { .statusitem { - background-color: #FAFAFA; color: #2C2C2C; - font-family: monospace; - font-size: 1em; - padding: 10px; + background-color : #d4d4d4; + font-size: .9em; + padding: 2px; + display: inline; + width: 20%; .show-results { margin-top: .3em; text-align:right; @@ -61,12 +70,12 @@ section.combined-open-ended-status { .show-results-button { font: 1em monospace; } - } + } .statusitem-current { - background-color: #d4d4d4; + background-color: #B2B2B2; color: #222; - } + } span { &.unanswered { @@ -98,8 +107,29 @@ section.combined-open-ended-status { } } -div.result-container { +div.combined-rubric-container { + ul.rubric-list{ + list-style-type: none; + padding:0; + margin:0; + li { + &.rubric-list-item{ + margin-bottom: 2px; + padding: 0px; + } + } + } + span.rubric-category { + font-size: .9em; + } + padding-bottom: 5px; + padding-top: 10px; +} + +div.result-container { + padding-top: 10px; + padding-bottom: 5px; .evaluation { p { @@ -113,9 +143,8 @@ div.result-container { } .evaluation-response { - margin-bottom: 10px; + margin-bottom: 2px; header { - text-align: right; a { font-size: .85em; } @@ -198,20 +227,6 @@ div.result-container { } } - .result-correct { - background: url('../images/correct-icon.png') left 20px no-repeat; - .result-actual-output { - color: #090; - } - } - - .result-incorrect { - background: url('../images/incorrect-icon.png') left 20px no-repeat; - .result-actual-output { - color: #B00; - } - } - .markup-text{ margin: 5px; padding: 20px 0px 15px 50px; @@ -229,6 +244,16 @@ div.result-container { } } } + .rubric-result-container { + .rubric-result { + font-size: .9em; + padding: 2px; + display: inline-table; + } + padding: 2px; + margin: 0px; + display : inline; + } } @@ -404,7 +429,7 @@ section.open-ended-child { div.short-form-response { background: #F6F6F6; border: 1px solid #ddd; - margin-bottom: 20px; + margin-bottom: 0px; overflow-y: auto; height: 200px; @include clearfix; @@ -478,6 +503,18 @@ section.open-ended-child { margin-left: .75rem; } + ul.rubric-list{ + list-style-type: none; + padding:0; + margin:0; + li { + &.rubric-list-item{ + margin-bottom: 0px; + padding: 0px; + } + } + } + ol { list-style: decimal outside none; margin-bottom: lh(); @@ -503,9 +540,8 @@ section.open-ended-child { } li { - line-height: 1.4em; - margin-bottom: lh(.5); - + margin-bottom: 0px; + padding: 0px; &:last-child { margin-bottom: 0; } diff --git a/common/lib/xmodule/xmodule/css/html/display.scss b/common/lib/xmodule/xmodule/css/html/display.scss index 956923c6d0..93138ac5a9 100644 --- a/common/lib/xmodule/xmodule/css/html/display.scss +++ b/common/lib/xmodule/xmodule/css/html/display.scss @@ -49,10 +49,18 @@ p { em, i { font-style: italic; + + span { + font-style: italic; + } } strong, b { font-weight: bold; + + span { + font-weight: bold; + } } p + p, ul + p, ol + p { diff --git a/common/lib/xmodule/xmodule/css/videoalpha/display.scss b/common/lib/xmodule/xmodule/css/videoalpha/display.scss new file mode 100644 index 0000000000..bf575e74a3 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/videoalpha/display.scss @@ -0,0 +1,559 @@ +& { + margin-bottom: 30px; +} + +div.video { + @include clearfix(); + background: #f3f3f3; + display: block; + margin: 0 -12px; + padding: 12px; + border-radius: 5px; + + article.video-wrapper { + float: left; + margin-right: flex-gutter(9); + width: flex-grid(6, 9); + + section.video-player { + height: 0; + overflow: hidden; + padding-bottom: 56.25%; + position: relative; + + object, iframe { + border: none; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + } + } + + section.video-controls { + @include clearfix(); + background: #333; + border: 1px solid #000; + border-top: 0; + color: #ccc; + position: relative; + + &:hover { + ul, div { + opacity: 1; + } + } + + div.slider { + @include clearfix(); + background: #c2c2c2; + border: 1px solid #000; + @include border-radius(0); + border-top: 1px solid #000; + @include box-shadow(inset 0 1px 0 #eee, 0 1px 0 #555); + height: 7px; + margin-left: -1px; + margin-right: -1px; + @include transition(height 2.0s ease-in-out); + + div.ui-widget-header { + background: #777; + @include box-shadow(inset 0 1px 0 #999); + } + + a.ui-slider-handle { + background: $pink url(../images/slider-handle.png) center center no-repeat; + @include background-size(50%); + border: 1px solid darken($pink, 20%); + @include border-radius(15px); + @include box-shadow(inset 0 1px 0 lighten($pink, 10%)); + cursor: pointer; + height: 15px; + margin-left: -7px; + top: -4px; + @include transition(height 2.0s ease-in-out, width 2.0s ease-in-out); + width: 15px; + + &:focus, &:hover { + background-color: lighten($pink, 10%); + outline: none; + } + } + } + + ul.vcr { + @extend .dullify; + float: left; + list-style: none; + margin: 0 lh() 0 0; + padding: 0; + + li { + float: left; + margin-bottom: 0; + + a { + border-bottom: none; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555); + cursor: pointer; + display: block; + line-height: 46px; + padding: 0 lh(.75); + text-indent: -9999px; + @include transition(background-color, opacity); + width: 14px; + background: url('../images/vcr.png') 15px 15px no-repeat; + outline: 0; + + &:focus { + outline: 0; + } + + &:empty { + height: 46px; + background: url('../images/vcr.png') 15px 15px no-repeat; + } + + &.play { + background-position: 17px -114px; + + &:hover { + background-color: #444; + } + } + + &.pause { + background-position: 16px -50px; + + &:hover { + background-color: #444; + } + } + } + + div.vidtime { + padding-left: lh(.75); + font-weight: bold; + line-height: 46px; //height of play pause buttons + padding-left: lh(.75); + -webkit-font-smoothing: antialiased; + } + } + } + + div.secondary-controls { + @extend .dullify; + float: right; + + div.speeds { + float: left; + position: relative; + + &.open { + &>a { + background: url('../images/open-arrow.png') 10px center no-repeat; + } + + ol.video_speeds { + display: block; + opacity: 1; + padding: 0; + margin: 0; + list-style: none; + } + } + + &>a { + background: url('../images/closed-arrow.png') 10px center no-repeat; + border-left: 1px solid #000; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555); + @include clearfix(); + color: #fff; + cursor: pointer; + display: block; + line-height: 46px; //height of play pause buttons + margin-right: 0; + padding-left: 15px; + position: relative; + @include transition(); + -webkit-font-smoothing: antialiased; + width: 116px; + outline: 0; + + &:focus { + outline: 0; + } + + h3 { + color: #999; + float: left; + font-size: em(14); + font-weight: normal; + letter-spacing: 1px; + padding: 0 lh(.25) 0 lh(.5); + line-height: 46px; + text-transform: uppercase; + } + + p.active { + float: left; + font-weight: bold; + margin-bottom: 0; + padding: 0 lh(.5) 0 0; + line-height: 46px; + color: #fff; + } + + &:hover, &:active, &:focus { + opacity: 1; + background-color: #444; + } + } + + // fix for now + ol.video_speeds { + @include box-shadow(inset 1px 0 0 #555, 0 3px 0 #444); + @include transition(); + background-color: #444; + border: 1px solid #000; + bottom: 46px; + display: none; + opacity: 0; + position: absolute; + width: 133px; + z-index: 10; + + li { + @include box-shadow( 0 1px 0 #555); + border-bottom: 1px solid #000; + color: #fff; + cursor: pointer; + + a { + border: 0; + color: #fff; + display: block; + padding: lh(.5); + + &:hover { + background-color: #666; + color: #aaa; + } + } + + &.active { + font-weight: bold; + } + + &:last-child { + @include box-shadow(none); + border-bottom: 0; + margin-top: 0; + } + } + } + } + + div.volume { + float: left; + position: relative; + + &.open { + .volume-slider-container { + display: block; + opacity: 1; + } + } + + &.muted { + &>a { + background: url('../images/mute.png') 10px center no-repeat; + } + } + + > a { + background: url('../images/volume.png') 10px center no-repeat; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555); + @include clearfix(); + color: #fff; + cursor: pointer; + display: block; + height: 46px; + margin-right: 0; + padding-left: 15px; + position: relative; + @include transition(); + -webkit-font-smoothing: antialiased; + width: 30px; + + &:hover, &:active, &:focus { + background-color: #444; + } + } + + .volume-slider-container { + @include box-shadow(inset 1px 0 0 #555, 0 3px 0 #444); + @include transition(); + background-color: #444; + border: 1px solid #000; + bottom: 46px; + display: none; + opacity: 0; + position: absolute; + width: 45px; + height: 125px; + margin-left: -1px; + z-index: 10; + + .volume-slider { + height: 100px; + border: 0; + width: 5px; + margin: 14px auto; + background: #666; + border: 1px solid #000; + @include box-shadow(0 1px 0 #333); + + a.ui-slider-handle { + background: $pink url(../images/slider-handle.png) center center no-repeat; + @include background-size(50%); + border: 1px solid darken($pink, 20%); + @include border-radius(15px); + @include box-shadow(inset 0 1px 0 lighten($pink, 10%)); + cursor: pointer; + height: 15px; + left: -6px; + @include transition(height 2.0s ease-in-out, width 2.0s ease-in-out); + width: 15px; + } + + .ui-slider-range { + background: #ddd; + } + } + } + } + + a.add-fullscreen { + background: url(../images/fullscreen.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; + } + } + + 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; + display: block; + float: left; + font-weight: 800; + line-height: 46px; //height of play pause buttons + margin-left: 0; + opacity: 1; + padding: 0 lh(.5); + position: relative; + text-indent: -9999px; + @include transition(); + -webkit-font-smoothing: antialiased; + width: 30px; + + &:hover { + background-color: #444; + color: #fff; + text-decoration: none; + } + + &.off { + opacity: .7; + } + } + } + } + + &:hover section.video-controls { + ul, div { + opacity: 1; + } + + div.slider { + height: 14px; + margin-top: -7px; + + a.ui-slider-handle { + @include border-radius(20px); + height: 20px; + margin-left: -10px; + top: -4px; + width: 20px; + } + } + } + } + + ol.subtitles { + padding-left: 0; + float: left; + max-height: 460px; + overflow: auto; + width: flex-grid(3, 9); + margin: 0; + font-size: 14px; + list-style: none; + + li { + border: 0; + color: #666; + cursor: pointer; + margin-bottom: 8px; + padding: 0; + line-height: lh(); + + &.current { + color: #333; + font-weight: 700; + } + + &:hover { + color: $blue; + } + + &:empty { + margin-bottom: 0px; + } + } + } + + &.closed { + @extend .trans; + + article.video-wrapper { + width: flex-grid(9,9); + } + + ol.subtitles { + width: 0; + height: 0; + } + } + + &.fullscreen { + background: rgba(#000, .95); + border: 0; + bottom: 0; + height: 100%; + left: 0; + margin: 0; + overflow: hidden; + padding: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 999; + vertical-align: middle; + + &.closed { + ol.subtitles { + right: -(flex-grid(4)); + width: auto; + } + } + + div.tc-wrapper { + @include clearfix; + display: table; + width: 100%; + height: 100%; + + article.video-wrapper { + width: 100%; + display: table-cell; + vertical-align: middle; + float: none; + } + + object, iframe { + bottom: 0; + height: 100%; + left: 0; + overflow: hidden; + position: fixed; + top: 0; + } + + section.video-controls { + bottom: 0; + left: 0; + position: absolute; + width: 100%; + z-index: 9999; + } + } + + ol.subtitles { + background: rgba(#000, .8); + bottom: 0; + height: 100%; + max-height: 100%; + max-width: flex-grid(3); + padding: lh(); + position: fixed; + right: 0; + top: 0; + @include transition(); + + li { + color: #aaa; + + &.current { + color: #fff; + } + } + } + } +} diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py new file mode 100644 index 0000000000..ea16fee7f1 --- /dev/null +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -0,0 +1,124 @@ +import logging +from lxml import etree +from dateutil import parser + +from pkg_resources import resource_string + +from xmodule.editing_module import EditingDescriptor +from xmodule.x_module import XModule +from xmodule.xml_module import XmlDescriptor + +log = logging.getLogger(__name__) + +class FolditModule(XModule): + 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) + # ooh look--I'm lazy, so hardcoding the 7.00x required level. + # If we need it generalized, can pull from the xml later + self.required_level = 4 + self.required_sublevel = 5 + + def parse_due_date(): + """ + Pull out the date, or None + """ + s = self.metadata.get("due") + if s: + return parser.parse(s) + else: + return None + + self.due_str = self.metadata.get("due", "None") + self.due = parse_due_date() + + def is_complete(self): + """ + Did the user get to the required level before the due date? + """ + # We normally don't want django dependencies in xmodule. foldit is + # special. Import this late to avoid errors with things not yet being + # initialized. + from foldit.models import PuzzleComplete + + complete = PuzzleComplete.is_level_complete( + self.system.anonymous_student_id, + self.required_level, + self.required_sublevel, + self.due) + return complete + + def completed_puzzles(self): + """ + Return a list of puzzles that this user has completed, as an array of + dicts: + + [ {'set': int, + 'subset': int, + 'created': datetime} ] + + The list is sorted by set, then subset + """ + from foldit.models import PuzzleComplete + + return sorted( + PuzzleComplete.completed_puzzles(self.system.anonymous_student_id), + key=lambda d: (d['set'], d['subset'])) + + + def get_html(self): + """ + Render the html for the module. + """ + goal_level = '{0}-{1}'.format( + self.required_level, + self.required_sublevel) + + context = { + 'due': self.due_str, + 'success': self.is_complete(), + 'goal_level': goal_level, + 'completed': self.completed_puzzles(), + } + + return self.system.render_template('foldit.html', context) + + + def get_score(self): + """ + 0 / 1 based on whether student has gotten far enough. + """ + score = 1 if self.is_complete() else 0 + return {'score': score, + 'total': self.max_score()} + + def max_score(self): + return 1 + + +class FolditDescriptor(XmlDescriptor, EditingDescriptor): + """ + Module for adding open ended response questions to courses + """ + mako_template = "widgets/html-edit.html" + module_class = FolditModule + filename_extension = "xml" + + stores_state = True + has_score = True + template_dir_name = "foldit" + + js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} + js_module_name = "HTMLEditingDescriptor" + + # The grade changes without any student interaction with the edx website, + # so always need to actually check. + always_recalculate_grades = True + + @classmethod + def definition_from_xml(cls, xml_object, system): + """ + For now, don't need anything from the xml + """ + return {} diff --git a/common/lib/xmodule/xmodule/hidden_module.py b/common/lib/xmodule/xmodule/hidden_module.py index d4f2a0fa33..e7639e63c8 100644 --- a/common/lib/xmodule/xmodule/hidden_module.py +++ b/common/lib/xmodule/xmodule/hidden_module.py @@ -3,7 +3,11 @@ from xmodule.raw_module import RawDescriptor class HiddenModule(XModule): - pass + def get_html(self): + if self.system.user_is_staff: + return "ERROR: This module is unknown--students will not see it at all" + else: + return "" class HiddenDescriptor(RawDescriptor): diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index af1ce0ad80..456ea3cf10 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -172,6 +172,13 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): elt.set("filename", relname) return elt + @property + def editable_metadata_fields(self): + """Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user.""" + subset = [field for field in super(HtmlDescriptor,self).editable_metadata_fields + if field not in ['empty']] + return subset + class AboutDescriptor(HtmlDescriptor): """ diff --git a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee index 9b8062d60d..5161e658e7 100644 --- a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee @@ -119,13 +119,13 @@ describe 'MarkdownEditingDescriptor', ->

    The answer is correct if it is within a specified numerical tolerance of the expected answer.

    Enter the numerical value of Pi:

    - +

    Enter the approximate value of 502*9:

    - + @@ -147,6 +147,20 @@ describe 'MarkdownEditingDescriptor', ->
    + """) + it 'will convert 0 as a numerical response (instead of string response)', -> + data = MarkdownEditingDescriptor.markdownToXml(""" + Enter 0 with a tolerance: + = 0 +- .02 + """) + expect(data).toEqual(""" +

    Enter 0 with a tolerance:

    + + + + + +
    """) it 'converts multiple choice to xml', -> data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets. diff --git a/common/lib/xmodule/xmodule/js/src/.gitignore b/common/lib/xmodule/xmodule/js/src/.gitignore index 03534687ca..bbd93c90e3 100644 --- a/common/lib/xmodule/xmodule/js/src/.gitignore +++ b/common/lib/xmodule/xmodule/js/src/.gitignore @@ -1,2 +1 @@ -*.js - +# Please do not ignore *.js files. Some xmodules are written in JS. diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index ae63171ed4..c749d65b45 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -1,14 +1,69 @@ class @Rubric constructor: () -> + @initialize: (location) -> + $('.rubric').data("location", location) + $('input[class="score-selection"]').change @tracking_callback + # set up the hotkeys + $(window).unbind('keydown', @keypress_callback) + $(window).keydown @keypress_callback + # display the 'current' carat + @categories = $('.rubric-category') + @category = $(@categories.first()) + @category.prepend('> ') + @category_index = 0 + + + @keypress_callback: (event) => + # don't try to do this when user is typing in a text input + if $(event.target).is('input, textarea') + return + # for when we select via top row + if event.which >= 48 and event.which <= 57 + selected = event.which - 48 + # for when we select via numpad + else if event.which >= 96 and event.which <= 105 + selected = event.which - 96 + # we don't want to do anything since we haven't pressed a number + else + return + + # if we actually have a current category (not past the end) + if(@category_index <= @categories.length) + # find the valid selections for this category + inputs = $("input[name='score-selection-#{@category_index}']") + max_score = inputs.length - 1 + + if selected > max_score or selected < 0 + return + inputs.filter("input[value=#{selected}]").click() + + # move to the next category + old_category_text = @category.html().substring(5) + @category.html(old_category_text) + @category_index++ + @category = $(@categories[@category_index]) + @category.prepend('> ') + + @tracking_callback: (event) -> + target_selection = $(event.target).val() + # chop off the beginning of the name so that we can get the number of the category + category = $(event.target).data("category") + location = $('.rubric').data('location') + # probably want the original problem location as well + + data = {location: location, selection: target_selection, category: category} + Logger.log 'rubric_select', data + + # finds the scores for each rubric category @get_score_list: () => # find the number of categories: - num_categories = $('table.rubric tr').length + num_categories = $('.rubric-category').length score_lst = [] # get the score for each one - for i in [0..(num_categories-2)] + for i in [0..(num_categories-1)] score = $("input[name='score-selection-#{i}']:checked").val() score_lst.push(score) @@ -23,9 +78,8 @@ class @Rubric @check_complete: () -> # check to see whether or not any categories have not been scored - num_categories = $('table.rubric tr').length - # -2 because we want to skip the header - for i in [0..(num_categories-2)] + num_categories = $('.rubric-category').length + for i in [0..(num_categories-1)] score = $("input[name='score-selection-#{i}']:checked").val() if score == undefined return false @@ -35,6 +89,7 @@ class @CombinedOpenEnded constructor: (element) -> @element=element @reinitialize(element) + $(window).keydown @keydown_handler reinitialize: (element) -> @wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule') @@ -46,28 +101,39 @@ class @CombinedOpenEnded @task_count = @el.data('task-count') @task_number = @el.data('task-number') @accept_file_upload = @el.data('accept-file-upload') + @location = @el.data('location') + # set up handlers for click tracking + Rubric.initialize(@location) @allow_reset = @el.data('allow_reset') @reset_button = @$('.reset-button') @reset_button.click @reset @next_problem_button = @$('.next-step-button') @next_problem_button.click @next_problem + @status_container = @$('.status-elements') @show_results_button=@$('.show-results-button') @show_results_button.click @show_results + @question_header = @$('.question-header') + @question_header.click @collapse_question + # valid states: 'initial', 'assessing', 'post_assessment', 'done' Collapsible.setCollapsibles(@el) @submit_evaluation_button = $('.submit-evaluation-button') @submit_evaluation_button.click @message_post @results_container = $('.result-container') + @combined_rubric_container = $('.combined-rubric-container') + + @legend_container= $('.legend-container') + @show_legend_current() # Where to put the rubric once we load it @el = $(element).find('section.open-ended-child') @errors_area = @$('.error') @answer_area = @$('textarea.answer') - + @prompt_container = @$('.prompt') @rubric_wrapper = @$('.rubric-wrapper') @hint_wrapper = @$('.hint-wrapper') @message_wrapper = @$('.message-wrapper') @@ -82,11 +148,22 @@ class @CombinedOpenEnded @can_upload_files = false @open_ended_child= @$('.open-ended-child') + @out_of_sync_message = 'The problem state got out of sync. Try reloading the page.' + + if @task_number>1 + @prompt_hide() + else if @task_number==1 and @child_state!='initial' + @prompt_hide() + @find_assessment_elements() @find_hint_elements() @rebind() + if @task_number>1 + @show_combined_rubric_current() + @show_results_current() + # locally scoped jquery. $: (selector) -> $(selector, @el) @@ -100,9 +177,12 @@ class @CombinedOpenEnded @submit_evaluation_button = $('.submit-evaluation-button') @submit_evaluation_button.click @message_post Collapsible.setCollapsibles(@results_container) + # make sure we still have click tracking + $('.evaluation-response a').click @log_feedback_click + $('input[name="evaluation-score"]').change @log_feedback_selection show_results: (event) => - status_item = $(event.target).parent().parent() + status_item = $(event.target).parent() status_number = status_item.data('status-number') data = {'task_number' : status_number} $.postWithPrefix "#{@ajax_url}/get_results", data, (response) => @@ -115,8 +195,28 @@ class @CombinedOpenEnded else @gentle_alert response.error + show_combined_rubric_current: () => + data = {} + $.postWithPrefix "#{@ajax_url}/get_combined_rubric", data, (response) => + if response.success + @combined_rubric_container.after(response.html).remove() + @combined_rubric_container= $('div.combined_rubric_container') + + show_status_current: () => + data = {} + $.postWithPrefix "#{@ajax_url}/get_status", data, (response) => + if response.success + @status_container.after(response.html).remove() + @status_container= $('.status-elements') + + show_legend_current: () => + data = {} + $.postWithPrefix "#{@ajax_url}/get_legend", data, (response) => + if response.success + @legend_container.after(response.html).remove() + @legend_container= $('.legend-container') + message_post: (event)=> - Logger.log 'message_post', @answers external_grader_message=$(event.target).parent().parent().parent() evaluation_scoring = $(event.target).parent() @@ -145,6 +245,7 @@ class @CombinedOpenEnded $('section.evaluation').slideToggle() @message_wrapper.html(response.message_html) + $.ajaxWithPrefix("#{@ajax_url}/save_post_assessment", settings) @@ -156,6 +257,11 @@ class @CombinedOpenEnded @next_problem_button.hide() @hide_file_upload() @hint_area.attr('disabled', false) + if @task_number>1 or @child_state!='initial' + @show_status_current() + + if @task_number==1 and @child_state=='assessing' + @prompt_hide() if @child_state == 'done' @rubric_wrapper.hide() if @child_type=="openended" @@ -241,6 +347,7 @@ class @CombinedOpenEnded if response.success @rubric_wrapper.html(response.rubric_html) @rubric_wrapper.show() + Rubric.initialize(@location) @answer_area.html(response.student_response) @child_state = 'assessing' @find_assessment_elements() @@ -251,13 +358,19 @@ class @CombinedOpenEnded $.ajaxWithPrefix("#{@ajax_url}/save_answer",settings) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) + + keydown_handler: (e) => + # only do anything when the key pressed is the 'enter' key + if e.which == 13 && @child_state == 'assessing' && Rubric.check_complete() + @save_assessment(e) save_assessment: (event) => event.preventDefault() if @child_state == 'assessing' && Rubric.check_complete() checked_assessment = Rubric.get_total_score() - data = {'assessment' : checked_assessment} + score_list = Rubric.get_score_list() + data = {'assessment' : checked_assessment, 'score_list' : score_list} $.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) => if response.success @child_state = response.state @@ -267,13 +380,12 @@ class @CombinedOpenEnded @find_hint_elements() else if @child_state == 'done' @rubric_wrapper.hide() - @message_wrapper.html(response.message_html) @rebind() else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) save_hint: (event) => event.preventDefault() @@ -288,7 +400,7 @@ class @CombinedOpenEnded else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) skip_post_assessment: => if @child_state == 'post_assessment' @@ -300,7 +412,7 @@ class @CombinedOpenEnded else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) reset: (event) => event.preventDefault() @@ -320,7 +432,7 @@ class @CombinedOpenEnded else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) next_problem: => if @child_state == 'done' @@ -343,7 +455,7 @@ class @CombinedOpenEnded else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) gentle_alert: (msg) => if @el.find('.open-ended-alert').length @@ -362,18 +474,18 @@ class @CombinedOpenEnded $.postWithPrefix "#{@ajax_url}/check_for_score", (response) => if response.state == "done" or response.state=="post_assessment" delete window.queuePollerID - location.reload() + @reload() else window.queuePollerID = window.setTimeout(@poll, 10000) setup_file_upload: => - if window.File and window.FileReader and window.FileList and window.Blob - if @accept_file_upload == "True" - @can_upload_files = true - @file_upload_area.html('') - @file_upload_area.show() - else - @gentle_alert 'File uploads are required for this question, but are not supported in this browser. Try the newest version of google chrome. Alternatively, if you have uploaded the image to the web, you can paste a link to it into the answer box.' + if @accept_file_upload == "True" + if window.File and window.FileReader and window.FileList and window.Blob + @can_upload_files = true + @file_upload_area.html('') + @file_upload_area.show() + else + @gentle_alert 'File uploads are required for this question, but are not supported in this browser. Try the newest version of google chrome. Alternatively, if you have uploaded the image to the web, you can paste a link to it into the answer box.' hide_file_upload: => if @accept_file_upload == "True" @@ -390,3 +502,40 @@ class @CombinedOpenEnded # wrap this so that it can be mocked reload: -> location.reload() + + collapse_question: () => + @prompt_container.slideToggle() + @prompt_container.toggleClass('open') + if @question_header.text() == "(Hide)" + new_text = "(Show)" + Logger.log 'oe_hide_question', {location: @location} + else + Logger.log 'oe_show_question', {location: @location} + new_text = "(Hide)" + @question_header.text(new_text) + + prompt_show: () => + if @prompt_container.is(":hidden")==true + @prompt_container.slideToggle() + @prompt_container.toggleClass('open') + @question_header.text("(Hide)") + + prompt_hide: () => + if @prompt_container.is(":visible")==true + @prompt_container.slideToggle() + @prompt_container.toggleClass('open') + @question_header.text("(Show)") + + log_feedback_click: (event) -> + link_text = $(event.target).html() + if link_text == 'See full feedback' + Logger.log 'oe_show_full_feedback', {} + else if link_text == 'Respond to Feedback' + Logger.log 'oe_show_respond_to_feedback', {} + else + generated_event_type = link_text.toLowerCase().replace(" ","_") + Logger.log "oe_" + generated_event_type, {} + + log_feedback_selection: (event) -> + target_selection = $(event.target).val() + Logger.log 'oe_feedback_response_selected', {value: target_selection} diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index deeb82900b..4bdb4bdf05 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -175,17 +175,23 @@ class @PeerGradingProblem @prompt_container = $('.prompt-container') @rubric_container = $('.rubric-container') @flag_student_container = $('.flag-student-container') + @answer_unknown_container = $('.answer-unknown-container') @calibration_panel = $('.calibration-panel') @grading_panel = $('.grading-panel') @content_panel = $('.content-panel') @grading_message = $('.grading-message') @grading_message.hide() + @question_header = $('.question-header') + @question_header.click @collapse_question @grading_wrapper =$('.grading-wrapper') @calibration_feedback_panel = $('.calibration-feedback') @interstitial_page = $('.interstitial-page') @interstitial_page.hide() + @calibration_interstitial_page = $('.calibration-interstitial-page') + @calibration_interstitial_page.hide() + @error_container = $('.error-container') @submission_key_input = $("input[name='submission-key']") @@ -201,7 +207,13 @@ class @PeerGradingProblem @action_button = $('.action-button') @calibration_feedback_button = $('.calibration-feedback-button') @interstitial_page_button = $('.interstitial-page-button') + @calibration_interstitial_page_button = $('.calibration-interstitial-page-button') @flag_student_checkbox = $('.flag-checkbox') + @answer_unknown_checkbox = $('.answer-unknown-checkbox') + + $(window).keydown @keydown_handler + + @collapse_question() Collapsible.setCollapsibles(@content_panel) @@ -210,12 +222,21 @@ class @PeerGradingProblem @calibration_feedback_button.click => @calibration_feedback_panel.hide() @grading_wrapper.show() + @gentle_alert "Calibration essay saved. Fetched the next essay." @is_calibrated_check() @interstitial_page_button.click => @interstitial_page.hide() @is_calibrated_check() + @calibration_interstitial_page_button.click => + @calibration_interstitial_page.hide() + @is_calibrated_check() + + @calibration_feedback_button.hide() + @calibration_feedback_panel.hide() + @error_container.hide() + @is_calibrated_check() @@ -243,6 +264,7 @@ class @PeerGradingProblem submission_key: @submission_key_input.val() feedback: @feedback_area.val() submission_flagged: @flag_student_checkbox.is(':checked') + answer_unknown: @answer_unknown_checkbox.is(':checked') return data @@ -273,6 +295,9 @@ class @PeerGradingProblem else if response.calibrated and @calibration == true @calibration = false @render_interstitial_page() + else if not response.calibrated and @calibration==null + @calibration=true + @render_calibration_interstitial_page() else @calibration = true @fetch_calibration_essay() @@ -296,7 +321,7 @@ class @PeerGradingProblem if response.success @is_calibrated_check() @grading_message.fadeIn() - @grading_message.html("

    Grade sent successfully.

    ") + @grading_message.html("

    Successfully saved your feedback. Fetched the next essay.

    ") else if response.error @render_error(response.error) @@ -308,9 +333,18 @@ class @PeerGradingProblem # check to see whether or not any categories have not been scored if Rubric.check_complete() # show button if we have scores for all categories + @grading_message.hide() @show_submit_button() @grade = Rubric.get_total_score() + keydown_handler: (event) => + if event.which == 13 && @submit_button.is(':visible') + if @calibration + @submit_calibration_essay() + else + @submit_grade() + + ########## @@ -323,7 +357,7 @@ class @PeerGradingProblem if response.success # load in all the data - @submission_container.html("

    Training Essay

    ") + @submission_container.html("") @render_submission_data(response) # TODO: indicate that we're in calibration mode @calibration_panel.addClass('current-state') @@ -337,6 +371,9 @@ class @PeerGradingProblem @calibration_panel.find('.grading-text').hide() @grading_panel.find('.grading-text').hide() @flag_student_container.hide() + @answer_unknown_container.hide() + + @feedback_area.val("") @submit_button.unbind('click') @submit_button.click @submit_calibration_essay @@ -350,7 +387,7 @@ class @PeerGradingProblem render_submission: (response) => if response.success @submit_button.hide() - @submission_container.html("

    Submitted Essay

    ") + @submission_container.html("") @render_submission_data(response) @calibration_panel.removeClass('current-state') @@ -364,6 +401,8 @@ class @PeerGradingProblem @calibration_panel.find('.grading-text').show() @grading_panel.find('.grading-text').show() @flag_student_container.show() + @answer_unknown_container.show() + @feedback_area.val("") @submit_button.unbind('click') @submit_button.click @submit_grade @@ -395,6 +434,7 @@ class @PeerGradingProblem @submit_button.hide() @action_button.hide() @calibration_feedback_panel.hide() + Rubric.initialize(@location) render_calibration_feedback: (response) => @@ -408,18 +448,25 @@ class @PeerGradingProblem actual_score = parseInt(response.actual_score) if score == actual_score - calibration_wrapper.append("

    Congratulations! Your score matches the actual score!

    ") + calibration_wrapper.append("

    Your score matches the actual score!

    ") else - calibration_wrapper.append("

    Please try to understand the grading critera better to be more accurate next time.

    ") + calibration_wrapper.append("

    You may want to review the rubric again.

    ") # disable score selection and submission from the grading interface $("input[name='score-selection']").attr('disabled', true) @submit_button.hide() + @calibration_feedback_button.show() render_interstitial_page: () => @content_panel.hide() + @grading_message.hide() @interstitial_page.show() + render_calibration_interstitial_page: () => + @content_panel.hide() + @action_button.hide() + @calibration_interstitial_page.show() + render_error: (error_message) => @error_container.show() @calibration_feedback_panel.hide() @@ -433,3 +480,18 @@ class @PeerGradingProblem setup_score_selection: (max_score) => # And now hook up an event handler again $("input[class='score-selection']").change @graded_callback + + gentle_alert: (msg) => + @grading_message.fadeIn() + @grading_message.html("

    " + msg + "

    ") + + collapse_question: () => + @prompt_container.slideToggle() + @prompt_container.toggleClass('open') + if @question_header.text() == "(Hide)" + Logger.log 'peer_grading_hide_question', {location: @location} + new_text = "(Show)" + else + Logger.log 'peer_grading_show_question', {location: @location} + new_text = "(Hide)" + @question_header.text(new_text) diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee index 2bfe483a7f..b723f230e9 100644 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee @@ -231,13 +231,14 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor // replace string and numerical xml = xml.replace(/^\=\s*(.*?$)/gm, function(match, p) { var string; - var params = /(.*?)\+\-\s*(.*?$)/.exec(p); - if(parseFloat(p)) { + var floatValue = parseFloat(p); + if(!isNaN(floatValue)) { + var params = /(.*?)\+\-\s*(.*?$)/.exec(p); if(params) { - string = '\n'; + string = '\n'; string += ' \n'; } else { - string = '\n'; + string = '\n'; } string += ' \n'; string += '\n\n'; diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee new file mode 100644 index 0000000000..a27362b094 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee @@ -0,0 +1,103 @@ +class @VideoAlpha + constructor: (element) -> + @el = $(element).find('.video') + @id = @el.attr('id').replace(/video_/, '') + @start = @el.data('start') + @end = @el.data('end') + @caption_data_dir = @el.data('caption-data-dir') + @caption_asset_path = @el.data('caption-asset-path') + @show_captions = @el.data('show-captions').toString() == "true" + @el = $("#video_#{@id}") + if @parseYoutubeId(@el.data("streams")) is true + @videoType = "youtube" + @fetchMetadata() + @parseSpeed() + else + @videoType = "html5" + @parseHtml5Sources @el.data('mp4-source'), @el.data('webm-source'), @el.data('ogg-source') + @speeds = ['0.75', '1.0', '1.25', '1.50'] + sub = @el.data('sub') + if (typeof sub isnt "string") or (sub.length is 0) + sub = "" + @show_captions = false + @videos = + "0.75": sub + "1.0": sub + "1.25": sub + "1.5": sub + @setSpeed $.cookie('video_speed') + $("#video_#{@id}").data('video', this).addClass('video-load-complete') + if @show_captions is true + @hide_captions = $.cookie('hide_captions') == 'true' + else + @hide_captions = true + $.cookie('hide_captions', @hide_captions, expires: 3650, path: '/') + @el.addClass 'closed' + if ((@videoType is "youtube") and (YT.Player)) or ((@videoType is "html5") and (HTML5Video.Player)) + @embed() + else + if @videoType is "youtube" + window.onYouTubePlayerAPIReady = => + @embed() + else if @videoType is "html5" + window.onHTML5PlayerAPIReady = => + @embed() + + youtubeId: (speed)-> + @videos[speed || @speed] + + parseYoutubeId: (videos)-> + return false if (typeof videos isnt "string") or (videos.length is 0) + @videos = {} + $.each videos.split(/,/), (index, video) => + speed = undefined + video = video.split(/:/) + speed = parseFloat(video[0]).toFixed(2).replace(/\.00$/, ".0") + @videos[speed] = video[1] + true + + parseHtml5Sources: (mp4Source, webmSource, oggSource)-> + @html5Sources = + mp4: null + webm: null + ogg: null + @html5Sources.mp4 = mp4Source if (typeof mp4Source is "string") and (mp4Source.length > 0) + @html5Sources.webm = webmSource if (typeof webmSource is "string") and (webmSource.length > 0) + @html5Sources.ogg = oggSource if (typeof oggSource is "string") and (oggSource.length > 0) + + parseSpeed: -> + @speeds = ($.map @videos, (url, speed) -> speed).sort() + @setSpeed $.cookie('video_speed') + + setSpeed: (newSpeed, updateCookie)-> + if @speeds.indexOf(newSpeed) isnt -1 + @speed = newSpeed + + if updateCookie isnt false + $.cookie "video_speed", "" + newSpeed, + expires: 3650 + path: "/" + else + @speed = "1.0" + + embed: -> + @player = new VideoPlayerAlpha video: this + + fetchMetadata: (url) -> + @metadata = {} + $.each @videos, (speed, url) => + $.get "https://gdata.youtube.com/feeds/api/videos/#{url}?v=2&alt=jsonc", ((data) => @metadata[data.data.id] = data.data) , 'jsonp' + + getDuration: -> + @metadata[@youtubeId()].duration + + log: (eventName)-> + logInfo = + id: @id + code: @youtubeId() + currentTime: @player.currentTime + speed: @speed + if @videoType is "youtube" + logInfo.code = @youtubeId() + else logInfo.code = "html5" if @videoType is "html5" + Logger.log eventName, logInfo diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee new file mode 100644 index 0000000000..6b86296dfa --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee @@ -0,0 +1,14 @@ +class @SubviewAlpha + constructor: (options) -> + $.each options, (key, value) => + @[key] = value + @initialize() + @render() + @bind() + + $: (selector) -> + $(selector, @el) + + initialize: -> + render: -> + bind: -> diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/html5_video.js b/common/lib/xmodule/xmodule/js/src/videoalpha/display/html5_video.js new file mode 100644 index 0000000000..c3cc462ab8 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/html5_video.js @@ -0,0 +1,294 @@ +this.HTML5Video = (function () { + var HTML5Video; + + HTML5Video = {}; + + HTML5Video.Player = (function () { + Player.prototype.callStateChangeCallback = function () { + if ($.isFunction(this.config.events.onStateChange) === true) { + this.config.events.onStateChange({ + 'data': this.playerState + }); + } + }; + + Player.prototype.pauseVideo = function () { + this.video.pause(); + }; + + Player.prototype.seekTo = function (value) { + if ((typeof value === 'number') && (value <= this.video.duration) && (value >= 0)) { + this.start = 0; + this.end = this.video.duration; + + this.video.currentTime = value; + } + }; + + Player.prototype.setVolume = function (value) { + if ((typeof value === 'number') && (value <= 100) && (value >= 0)) { + this.video.volume = value * 0.01; + } + }; + + Player.prototype.getCurrentTime = function () { + return this.video.currentTime; + }; + + Player.prototype.playVideo = function () { + this.video.play(); + }; + + Player.prototype.getPlayerState = function () { + return this.playerState; + }; + + Player.prototype.getVolume = function () { + return this.video.volume; + }; + + Player.prototype.getDuration = function () { + if (isFinite(this.video.duration) === false) { + return 0; + } + + return this.video.duration; + }; + + Player.prototype.setPlaybackRate = function (value) { + var newSpeed; + + newSpeed = parseFloat(value); + + if (isFinite(newSpeed) === true) { + this.video.playbackRate = value; + } + }; + + Player.prototype.getAvailablePlaybackRates = function () { + return [0.75, 1.0, 1.25, 1.5]; + }; + + return Player; + + /* + * Constructor function for HTML5 Video player. + * + * @el - A DOM element where the HTML5 player will be inserted (as returned by jQuery(selector) function), + * or a selector string which will be used to select an element. This is a required parameter. + * + * @config - An object whose properties will be used as configuration options for the HTML5 video + * player. This is an optional parameter. In the case if this parameter is missing, or some of the config + * object's properties are missing, defaults will be used. The available options (and their defaults) are as + * follows: + * + * config = { + * + * 'videoSources': {}, // An object with properties being video sources. The property name is the + * // video format of the source. Supported video formats are: 'mp4', 'webm', and + * // 'ogg'. + * + * 'playerVars': { // Object's properties identify player parameters. + * 'start': 0, // Possible values: positive integer. Position from which to start playing the + * // video. Measured in seconds. If value is non-numeric, or 'start' property is + * // not specified, the video will start playing from the beginning. + * + * 'end': null // Possible values: positive integer. Position when to stop playing the + * // video. Measured in seconds. If value is null, or 'end' property is not + * // specified, the video will end playing at the end. + * + * }, + * + * 'events': { // Object's properties identify the events that the API fires, and the + * // functions (event listeners) that the API will call when those events occur. + * // If value is null, or property is not specified, then no callback will be + * // called for that event. + * + * 'onReady': null, + * 'onStateChange': null + * } + * } + */ + function Player(el, config) { + var sourceStr, _this; + + // If el is string, we assume it is an ID of a DOM element. Get the element, and check that the ID + // really belongs to an element. If we didn't get a DOM element, return. At this stage, nothing will + // break because other parts of the video player are waiting for 'onReady' callback to be called. + if (typeof el === 'string') { + this.el = $(el); + + if (this.el.length === 0) { + return; + } + } else if (el instanceof jQuery) { + this.el = el; + } else { + return; + } + + // A simple test to see that the 'config' is a normal object. + if ($.isPlainObject(config) === true) { + this.config = config; + } else { + return; + } + + // We should have at least one video source. Otherwise there is no point to continue. + if (config.hasOwnProperty('videoSources') === false) { + return; + } + + // From the start, all sources are empty. We will populate this object below. + sourceStr = { + 'mp4': ' ', + 'webm': ' ', + 'ogg': ' ' + }; + + // Will be used in inner functions to point to the current object. + _this = this; + + // Create HTML markup for individual sources of the HTML5
    +
  • + + +
    + McGillX +
    +
    +
  • +
  • + + +
    + ANUx +
    +
    +
  • + + +
    + +
    1. @@ -94,7 +110,7 @@
    -
  • +
  • @@ -102,6 +118,38 @@
  • +
  • + + +
    + University of TorontoX +
    +
    +
  • +
  • + + +
    + EPFLx +
    +
    +
  • +
  • + + +
    + DelftX +
    +
    +
  • +
  • + + +
    + RiceX +
    +
    +
  • diff --git a/lms/templates/instructor/staff_grading.html b/lms/templates/instructor/staff_grading.html index dcfece34b8..1c5f7364ad 100644 --- a/lms/templates/instructor/staff_grading.html +++ b/lms/templates/instructor/staff_grading.html @@ -42,15 +42,13 @@

    -

    Problem Information

    -

    Maching Learning Information

    -

    Question

    +

    Prompt (Hide)

    @@ -62,11 +60,10 @@
    -

    Grading

    -

    Student Submission

    +

    Student Response

    @@ -78,6 +75,9 @@

    Written Feedback

    +

    + Flag as inappropriate content for later review +

    diff --git a/lms/templates/open_ended.html b/lms/templates/open_ended.html index 5697a5ab8b..9fb136cee6 100644 --- a/lms/templates/open_ended.html +++ b/lms/templates/open_ended.html @@ -3,7 +3,7 @@
    ${prompt|n}
    -

    Answer

    +

    Response

    @@ -15,7 +15,12 @@ % elif state in ['done', 'post_assessment'] and correct == 'incorrect':

    Incorrect.

    % elif state == 'assessing': - Submitted for grading. + Submitted for grading. + % if eta_message is not None: + ${eta_message} + % endif + + % endif % if hidden: diff --git a/lms/templates/open_ended_combined_rubric.html b/lms/templates/open_ended_combined_rubric.html new file mode 100644 index 0000000000..61393cdc95 --- /dev/null +++ b/lms/templates/open_ended_combined_rubric.html @@ -0,0 +1,28 @@ +
    + % for i in range(len(categories)): + <% category = categories[i] %> + ${category['description']}
    +
      + % for j in range(len(category['options'])): + <% option = category['options'][j] %> +
    • +
      + %for grader_type in category['options'][j]['grader_types']: + % if grader_type in grader_type_image_dict: + <% grader_image = grader_type_image_dict[grader_type] %> + % if grader_type in human_grader_types: + <% human_title = human_grader_types[grader_type] %> + % else: + <% human_title = grader_type %> + % endif + + % endif + %endfor + ${option['points']} points : ${option['text']} +
      +
    • + % endfor +
    + % endfor +
    + diff --git a/lms/templates/open_ended_feedback.html b/lms/templates/open_ended_feedback.html index 7fffddb88f..e16aea0b53 100644 --- a/lms/templates/open_ended_feedback.html +++ b/lms/templates/open_ended_feedback.html @@ -1,17 +1,10 @@
    -
    Feedback
    -
    -
    -

    Score: ${score}

    - % if grader_type == "ML": -

    Check below for full feedback:

    - % endif -
    -
    -
    -
    - ${ feedback | n} -
    +
    ${rubric_feedback | n} + % if grader_type=="PE": +
    + ${ feedback | n} +
    + % endif
    diff --git a/lms/templates/open_ended_problems/combined_notifications.html b/lms/templates/open_ended_problems/combined_notifications.html index 9de6ef3273..deb66b6064 100644 --- a/lms/templates/open_ended_problems/combined_notifications.html +++ b/lms/templates/open_ended_problems/combined_notifications.html @@ -33,7 +33,7 @@
    ${notification['name']}
    %if notification['alert']: -

    ${notification['alert_message']}

    +

    ${notification['alert_message']}

    %endif

    ${notification['description']}

    diff --git a/lms/templates/open_ended_problems/open_ended_problems.html b/lms/templates/open_ended_problems/open_ended_problems.html index 07d379fe32..3709fb2de6 100644 --- a/lms/templates/open_ended_problems/open_ended_problems.html +++ b/lms/templates/open_ended_problems/open_ended_problems.html @@ -27,7 +27,8 @@ Problem Name Status - Type of Grading + Grader Type + ETA %for problem in problem_list: @@ -40,6 +41,9 @@ ${problem['grader_type']} + + ${problem['eta_string']} + %endfor diff --git a/lms/templates/open_ended_result_table.html b/lms/templates/open_ended_result_table.html new file mode 100644 index 0000000000..24bf7a76fe --- /dev/null +++ b/lms/templates/open_ended_result_table.html @@ -0,0 +1,58 @@ +% for co in context_list: + % if co['grader_type'] in grader_type_image_dict: + <%grader_type=co['grader_type']%> + <% grader_image = grader_type_image_dict[grader_type] %> + % if grader_type in human_grader_types: + <% human_title = human_grader_types[grader_type] %> + % else: + <% human_title = grader_type %> + % endif +
    +
    + +
    +
    + ${co['rubric_html']} +
    +
    + %if len(co['feedback'])>2: +
    +
    + See full feedback +
    + +
    + %endif +
    + %if grader_type!="SA": +
    + + +
    +
    + Respond to Feedback +
    +
    +

    How accurate do you find this feedback?

    +
    +
      +
    • +
    • +
    • +
    • +
    • +
    +
    +

    Additional comments:

    + + +
    +
    +
    + %endif +
    +
    + %endif +%endfor \ No newline at end of file diff --git a/lms/templates/open_ended_rubric.html b/lms/templates/open_ended_rubric.html index eb3fc564b4..144cd829d9 100644 --- a/lms/templates/open_ended_rubric.html +++ b/lms/templates/open_ended_rubric.html @@ -1,44 +1,25 @@ -
    +

    Rubric

    - % if view_only and has_score: -

    This is the rubric that was used to grade your submission. The highlighted selection matches how the grader feels you performed in each category.

    - % elif view_only: -

    Use the below rubric to rate this submission.

    - % else:

    Select the criteria you feel best represents this submission in each category.

    - % endif - - - - % for i in range(max_score + 1): - - % endfor - +
    % for i in range(len(categories)): - <% category = categories[i] %> -
    - - % for j in range(len(category['options'])): + <% category = categories[i] %> + ${category['description']}
    +
      + % for j in range(len(category['options'])): <% option = category['options'][j] %> - %if option['selected']: -
    - % endfor - + %if option['selected']: +
  • + %else: +
  • + % endif + +
  • + % endfor + % endfor -
    - ${i} points -
    ${category['description']} - %else: - - % endif - % if view_only: - ## if this is the selected rubric block, show it highlighted -
    - ${option['text']} -
    - % else: - - - % endif -
    +
    diff --git a/lms/templates/open_ended_view_only_rubric.html b/lms/templates/open_ended_view_only_rubric.html new file mode 100644 index 0000000000..7cd9370c47 --- /dev/null +++ b/lms/templates/open_ended_view_only_rubric.html @@ -0,0 +1,12 @@ +
    + % for i in range(len(categories)): + <% category = categories[i] %> + % for j in range(len(category['options'])): + <% option = category['options'][j] %> + % if option['selected']: + ${category['description']} : ${option['points']} | + % endif + % endfor + % endfor +
    + diff --git a/lms/templates/peer_grading/peer_grading.html b/lms/templates/peer_grading/peer_grading.html index d309b4486c..0485b698b2 100644 --- a/lms/templates/peer_grading/peer_grading.html +++ b/lms/templates/peer_grading/peer_grading.html @@ -14,6 +14,7 @@ + @@ -22,7 +23,18 @@ %for problem in problem_list: +
    Problem NameDue date Graded Available Required
    - ${problem['problem_name']} + %if problem['closed']: + ${problem['problem_name']} + %else: + ${problem['problem_name']} + %endif + + % if problem['due']: + ${problem['due']} + % else: + No due date + % endif ${problem['num_graded']} diff --git a/lms/templates/peer_grading/peer_grading_closed.html b/lms/templates/peer_grading/peer_grading_closed.html new file mode 100644 index 0000000000..712ad8b380 --- /dev/null +++ b/lms/templates/peer_grading/peer_grading_closed.html @@ -0,0 +1,10 @@ +
    +

    Peer Grading

    +

    The due date has passed, and + % if use_for_single_location: + peer grading for this problem is closed at this time. + %else: + peer grading is closed at this time. + %endif +

    +
    diff --git a/lms/templates/peer_grading/peer_grading_problem.html b/lms/templates/peer_grading/peer_grading_problem.html index 5963691700..87559ec877 100644 --- a/lms/templates/peer_grading/peer_grading_problem.html +++ b/lms/templates/peer_grading/peer_grading_problem.html @@ -3,30 +3,17 @@
    -

    Peer Grading

    Learning to Grade

    -
    -

    Before you can do any proper peer grading, you first need to understand how your own grading compares to that of the instrutor. Once your grades begin to match the instructor's, you will move on to grading your peers!

    -
    -
    -

    You have successfully managed to calibrate your answers to that of the instructors and have moved onto the next step in the peer grading process.

    -
    -

    Grading

    -
    -

    You cannot start grading until you have graded a sufficient number of training problems and have been able to demonstrate that your scores closely match that of the instructor.

    -
    -
    -

    Now that you have finished your training, you are now allowed to grade your peers. Please keep in mind that students are allowed to respond to the grades and feedback they receive.

    -
    +

    Peer Grading

    -

    Question

    +

    Prompt (Hide)

    @@ -34,11 +21,11 @@
    -
    +
    -

    Grading

    +

    Student Response

    @@ -56,7 +43,8 @@

    Please include some written feedback as well.

    -

    Flag this submission for review by course staff (use if the submission contains inappropriate content):

    +
    Flag this submission for review by course staff (use if the submission contains inappropriate content)
    +
    I do not know how to grade this question
    @@ -70,7 +58,6 @@
    -

    How did I do?

    @@ -81,11 +68,20 @@
    -

    Congratulations!

    -

    You have now completed the calibration step. You are now ready to start grading.

    +

    Ready to grade!

    +

    You have finished learning to grade, which means that you are now ready to start grading.

    + +
    +

    Learning to grade

    +

    You have not yet finished learning to grade this problem.

    +

    You will now be shown a series of instructor-scored essays, and will be asked to score them yourself.

    +

    Once you can score the essays similarly to an instructor, you will be ready to grade your peers.

    + +
    +
    diff --git a/lms/templates/quickedit.html b/lms/templates/quickedit.html deleted file mode 100644 index bc8e74eb65..0000000000 --- a/lms/templates/quickedit.html +++ /dev/null @@ -1,180 +0,0 @@ -<%namespace name='static' file='static_content.html'/> - - -## ----------------------------------------------------------------------------- -## Template for courseware.views.quickedit -## -## Used for quick-edit link present when viewing capa-format assesment problems. -## ----------------------------------------------------------------------------- - - -## -## - -% if settings.MITX_FEATURES['USE_DJANGO_PIPELINE']: - <%static:css group='application'/> -% endif - -% if not settings.MITX_FEATURES['USE_DJANGO_PIPELINE']: -## -% endif - - - - - -% if settings.MITX_FEATURES['USE_DJANGO_PIPELINE']: - <%static:js group='application'/> -% endif - -% if not settings.MITX_FEATURES['USE_DJANGO_PIPELINE']: - % for jsfn in [ '/static/%s' % x.replace('.coffee','.js') for x in settings.PIPELINE_JS['application']['source_filenames'] ]: - - % endfor -% endif - -## codemirror - - - -## alternate codemirror -## -## -## - -## image input: for clicking on images (see imageinput.html) - - -## - - - -<%block name="headextra"/> - - - <%include file="mathjax_include.html" /> - - - - - - - -## ----------------------------------------------------------------------------- -## information and i4x PSL code - -
    -

    QuickEdit

    -
    -
      -
    • File = ${filename}
    • -
    • ID = ${id}
    • -
    - -
    - -
    - - - -
    - -${msg|n} - -## ----------------------------------------------------------------------------- -## rendered problem display - - - -
    - - - - - - - -
    -
    -
    - ${phtml} -
    -
    -
    - - - - - -## - - - - - - - - <%block name="js_extra"/> - - - diff --git a/lms/templates/self_assessment_hint.html b/lms/templates/self_assessment_hint.html index 1adfc69e39..8c6eacba11 100644 --- a/lms/templates/self_assessment_hint.html +++ b/lms/templates/self_assessment_hint.html @@ -1,6 +1,6 @@
    - ${hint_prompt} + Please enter a hint below:
    diff --git a/lms/templates/self_assessment_prompt.html b/lms/templates/self_assessment_prompt.html index 364009b134..5347e23844 100644 --- a/lms/templates/self_assessment_prompt.html +++ b/lms/templates/self_assessment_prompt.html @@ -5,7 +5,7 @@ ${prompt}
    -

    Answer

    +

    Response

    @@ -14,9 +14,9 @@
    ${initial_rubric}
    -
    ${initial_hint}
    +
    -
    ${initial_message}
    +
    diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 61cda0c52b..9324445dd1 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -1,5 +1,6 @@ ${module_content} -%if edit_link: +%if location.category in ['problem','video','html']: +% if edit_link:
    Edit / QA
    -% endif +% endif @@ -50,7 +50,7 @@

    What will the scope of the online courses be? How many? Which faculty?

    -

    Our goal is to offer a wide variety of courses across disciplines. There are currently nine courses offered for Fall 2012.

    +

    Our goal is to offer a wide variety of courses across disciplines. There are currently fifteen offered on the edX platform.

    Who is the learner? Domestic or international? Age range?

    diff --git a/lms/templates/static_templates/help.html b/lms/templates/static_templates/help.html index e150b5dbc8..417033fe0e 100644 --- a/lms/templates/static_templates/help.html +++ b/lms/templates/static_templates/help.html @@ -298,6 +298,12 @@

    There’s no need to delete you account. An old, unused edX account with no course completions associated with it will disappear.

    +
    +

    I made a mistake creating my username – how do I fix it?

    +
    +

    In most cases it would simplest and fastest to create a new account. Your old unused account will vanish naturally. If you were not aware of your mistake until much later, you should send us a detailed change request and we will do our best to edit your username. Please bear in mind that usernames are unique, and the one you want may be taken.

    +
    +

    I am experiencing problems with the display. E.g., There are tools missing from the course display, or I am unable to view video.

    diff --git a/lms/templates/static_templates/press_releases/edx_expands_internationally.html b/lms/templates/static_templates/press_releases/edx_expands_internationally.html new file mode 100644 index 0000000000..0ee42dafa9 --- /dev/null +++ b/lms/templates/static_templates/press_releases/edx_expands_internationally.html @@ -0,0 +1,81 @@ +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../../main.html" /> + +<%namespace name='static' file='../../static_content.html'/> + +<%block name="title">edX Expands Internationally and Doubles its Institutional Membership with the Addition of Six New Schools +
    + + +
    +
    +

    edX Expands Internationally and Doubles its Institutional Membership with the Addition of Six New Schools

    +
    +
    +

    edX welcomes The Australian National University, Delft University of Technology, École Polytechnique Fédérale de Lausanne, McGill University, Rice University and University of Toronto to its X University Consortium of the world’s leading higher education institutions

    + +

    CAMBRIDGE, MA – Feb. 20, 2013 – +EdX, the not-for-profit online learning enterprise founded by Harvard University and the Massachusetts Institute of Technology (MIT), announced today the international expansion of its X University Consortium with the addition of six new global higher education institutions. The Australian National University (ANU), Delft University of Technology in the Netherlands, École Polytechnique Fédérale de Lausanne (EPFL) in Switzerland, McGill University and the University of Toronto in Canada, and Rice University in the United States are joining the Consortium and will use the edX platform to deliver the next generation of online and blended courses. This international expansion enables edX to better achieve its mission of providing world-class courses to everyone, everywhere, and is the natural next step to continue serving the large international student body already using edX on a daily basis. +

    + +

    While MOOCs, or massive open online courses, have typically focused on offering a variety of online courses inexpensively or for free, edX's vision is much larger. EdX is building an open source educational platform and a network of the world's top universities to improve education both online and on campus while conducting research on how students learn. To date, edX has more than 700,000 individuals on its platform, who account for more than 900,000 course enrollments. The addition of these new higher education institutions stretching from North America to Europe to the Asia Pacific will double the number of X University Consortium members and add a rich variety of new courses to edX’s offerings: +

    + +
      +
    • The Australian National University, a celebrated place of intensive research, education and policy engagement, will provide a series of ANUx courses to the open source platform including Astrophysics taught by Nobel Laureate and Professor of Astrophysics Brian Schmidt and his colleague Dr. Paul Francis, and Engaging India, taught by Dr. McComas Taylor and Dr. Peter Friedlander.
    • + +
    • Delft University of Technology, the largest and oldest technological university in the Netherlands, will provide a series of DelftX courses under Creative Commons license, including Introduction to Aerospace Engineering by Professor Jacco Hoekstra, Solar Energy by Dr. Arno Smets, and Water Treatment Engineering by Professor Jules van Lier.
    • + +
    • École Polytechnique Fédérale de Lausanne, one of the most famous institutions of science and technology in Europe, will provide a series of EPFLx courses specially tailored to fit the edX format, originating from its five schools -- Engineering, Life Sciences, Informatics and Communication, Architecture and Basic Sciences.
    • + +
    • McGill University, one of Canada's best-known institutions of higher learning and one of the leading universities in the world, will provide a series of McGillX courses in areas ranging from science and the humanities to public policy issues.
    • + +
    • Rice University, in Houston, Texas, is consistently ranked among the nation's top 20 universities by U.S. News & World Report. Rice has highly respected schools of Architecture, Business, Continuing Studies, Engineering, Humanities, Music, Natural Sciences and Social Sciences and is home to the Baker Institute for Public Policy. Rice's Smalley Institute for Nanoscale Science and Technology was the world’s first nanotechnology center when it opened in 1991. Rice will initially provide four RiceX courses and investigate ways to integrate its learning analytics tools from OpenStax Tutor to enable students and instructors to track their progress in real time.
    • + +
    • University of Toronto, one of the most respected and influential institutions of higher education and advanced research in the world, will provide a series of TorontoX courses including Terrestrial Energy System by Professor Bryan Kanrey, Behavioral Economics by Professor Dilip Soman, The Logic of Business: Building Blocks for Organizational Design by Professor Mihnea Moldoveanu, and Bioinformatic Methods by Professor Nicholas Provart.
    • +
    + +

    “We have had an international student community from the very beginning, and bringing these leading universities, from North America and Europe and the Asia Pacific into the edX organization will help us meet the tremendous demand we are experiencing,” said Anant Agarwal, President of edX. “Each of these schools was carefully selected for the distinct expertise they bring to our growing family of edX institutions. We remain committed to growing edX to meet the needs of the world while maintaining a superior learning experience for all.”

    + +

    Courses offered by institutions on the edX platform provide the same rigor as on-campus classes but are designed to take advantage of the unique features and benefits of online learning environments, including game-like experiences, instant feedback and cutting-edge virtual laboratories. Through edX, the new X Universities will provide interactive education experiences for students around the world. All that is required of edX students is access to the Internet and a desire to learn. By breaking down the barriers of location and cost and enabling the global exchange of information and ideas, edX is changing the foundations of both teaching and learning.

    + +

    The new member institutions will join founding universities MIT and Harvard, as well as the University of California, Berkeley, the University of Texas System, Wellesley College and Georgetown University in the X University Consortium. ANUx, DelftX, EPFLx, McGillX, RiceX and TorontoX will offer courses on edX beginning in late 2013. All of the courses will be hosted on edX’s open source platform at www.edx.org. +

    + +

    About edX

    + +

    EdX is a not-for-profit enterprise of its founding partners Harvard University and the Massachusetts Institute of Technology focused on transforming online and on-campus learning through groundbreaking methodologies, game-like experiences and cutting-edge research. EdX provides inspirational and transformative knowledge to students of all ages, social status, and income who form worldwide communities of learners. EdX uses its open source technology to transcend physical and social borders. We’re focused on people, not profit. EdX is based in Cambridge, Massachusetts in the USA.

    + + +
    +

    Media Contact:

    +

    Dan O'Connell

    +

    oconnell@edx.org

    +

    (617) 480-6585

    +
    + + +
    +
    +
    diff --git a/lms/templates/static_templates/press_releases/template.html b/lms/templates/static_templates/press_releases/template.html index bf2ba9bc6f..52eebf49f5 100644 --- a/lms/templates/static_templates/press_releases/template.html +++ b/lms/templates/static_templates/press_releases/template.html @@ -33,10 +33,10 @@ Text

    -

    Contact:

    -

    Brad Baker, Weber Shandwick for edX

    -

    BBaker@webershandwick.com

    -

    (617) 520-7043

    +

    Media Contact:

    +

    Dan O'Connell

    +

    oconnell@edx.org

    +

    (617) 480-6585