Commit 5f125e68 by David Ormsbee

Merge branch 'master' into jmpm-analytics

parents cc82ffe1 fb407e3c
...@@ -28,4 +28,5 @@ nosetests.xml ...@@ -28,4 +28,5 @@ nosetests.xml
cover_html/ cover_html/
.idea/ .idea/
.redcar/ .redcar/
chromedriver.log chromedriver.log
\ No newline at end of file ghostdriver.log
source :rubygems source 'https://rubygems.org'
gem 'rake', '~> 10.0.3' gem 'rake', '~> 10.0.3'
gem 'sass', '3.1.15' gem 'sass', '3.1.15'
gem 'bourbon', '~> 1.3.6' gem 'bourbon', '~> 1.3.6'
......
...@@ -7,6 +7,7 @@ Feature: Advanced (manual) course policy ...@@ -7,6 +7,7 @@ Feature: Advanced (manual) course policy
When I select the Advanced Settings When I select the Advanced Settings
Then I see only the display name Then I see only the display name
@skip-phantom
Scenario: Test if there are no policy settings without existing UI controls Scenario: Test if there are no policy settings without existing UI controls
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
When I delete the display name When I delete the display name
...@@ -14,6 +15,7 @@ Feature: Advanced (manual) course policy ...@@ -14,6 +15,7 @@ Feature: Advanced (manual) course policy
And I reload the page And I reload the page
Then there are no advanced policy settings Then there are no advanced policy settings
@skip-phantom
Scenario: Test cancel editing key name Scenario: Test cancel editing key name
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
When I edit the name of a policy key When I edit the name of a policy key
...@@ -32,6 +34,7 @@ Feature: Advanced (manual) course policy ...@@ -32,6 +34,7 @@ Feature: Advanced (manual) course policy
And I press the "Cancel" notification button And I press the "Cancel" notification button
Then the policy key value is unchanged Then the policy key value is unchanged
@skip-phantom
Scenario: Test editing key value Scenario: Test editing key value
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key When I edit the value of a policy key
......
from lettuce import world, step from lettuce import world, step
from common import * from common import *
import time import time
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.support import expected_conditions as EC
from nose.tools import assert_equal from nose.tools import assert_true, assert_false, assert_equal
from nose.tools import assert_true
""" """
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
...@@ -19,6 +20,7 @@ def i_select_advanced_settings(step): ...@@ -19,6 +20,7 @@ def i_select_advanced_settings(step):
css_click(expand_icon_css) css_click(expand_icon_css)
link_css = 'li.nav-course-settings-advanced a' link_css = 'li.nav-course-settings-advanced a'
css_click(link_css) css_click(link_css)
# world.browser.click_link_by_text('Advanced Settings')
@step('I am on the Advanced Course Settings page in Studio$') @step('I am on the Advanced Course Settings page in Studio$')
...@@ -37,13 +39,25 @@ def reload_the_page(step): ...@@ -37,13 +39,25 @@ def reload_the_page(step):
def edit_the_name_of_a_policy_key(step): def edit_the_name_of_a_policy_key(step):
policy_key_css = 'input.policy-key' policy_key_css = 'input.policy-key'
e = css_find(policy_key_css).first e = css_find(policy_key_css).first
e.fill('new') e.type('_new')
@step(u'I press the "([^"]*)" notification button$') @step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name): def press_the_notification_button(step, name):
world.browser.click_link_by_text(name) def is_visible(driver):
return EC.visibility_of_element_located((By.CSS_SELECTOR,css,))
def is_invisible(driver):
return EC.invisibility_of_element_located((By.CSS_SELECTOR,css,))
css = 'a.%s-button' % name.lower()
wait_for(is_visible)
try:
css_click_at(css)
wait_for(is_invisible)
except WebDriverException, e:
css_click_at(css)
wait_for(is_invisible)
@step(u'I edit the value of a policy key$') @step(u'I edit the value of a policy key$')
def edit_the_value_of_a_policy_key(step): def edit_the_value_of_a_policy_key(step):
...@@ -83,7 +97,12 @@ def i_see_only_display_name(step): ...@@ -83,7 +97,12 @@ def i_see_only_display_name(step):
@step('there are no advanced policy settings$') @step('there are no advanced policy settings$')
def no_policy_settings(step): def no_policy_settings(step):
assert_policy_entries([], []) keys_css = 'input.policy-key'
val_css = 'textarea.json'
k = world.browser.is_element_not_present_by_css(keys_css, 5)
v = world.browser.is_element_not_present_by_css(val_css, 5)
assert_true(k)
assert_true(v)
@step('they are alphabetized$') @step('they are alphabetized$')
...@@ -99,29 +118,29 @@ def it_is_formatted(step): ...@@ -99,29 +118,29 @@ def it_is_formatted(step):
@step(u'the policy key name is unchanged$') @step(u'the policy key name is unchanged$')
def the_policy_key_name_is_unchanged(step): def the_policy_key_name_is_unchanged(step):
policy_key_css = 'input.policy-key' policy_key_css = 'input.policy-key'
e = css_find(policy_key_css).first val = css_find(policy_key_css).first.value
assert_equal(e.value, 'display_name') assert_equal(val, 'display_name')
@step(u'the policy key name is changed$') @step(u'the policy key name is changed$')
def the_policy_key_name_is_changed(step): def the_policy_key_name_is_changed(step):
policy_key_css = 'input.policy-key' policy_key_css = 'input.policy-key'
e = css_find(policy_key_css).first val = css_find(policy_key_css).first.value
assert_equal(e.value, 'new') assert_equal(val, 'display_name_new')
@step(u'the policy key value is unchanged$') @step(u'the policy key value is unchanged$')
def the_policy_key_value_is_unchanged(step): def the_policy_key_value_is_unchanged(step):
policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
e = css_find(policy_value_css).first val = css_find(policy_value_css).first.value
assert_equal(e.value, '"Robot Super Course"') assert_equal(val, '"Robot Super Course"')
@step(u'the policy key value is changed$') @step(u'the policy key value is changed$')
def the_policy_key_value_is_unchanged(step): def the_policy_key_value_is_unchanged(step):
policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
e = css_find(policy_value_css).first val = css_find(policy_value_css).first.value
assert_equal(e.value, '"Robot Super Course X"') assert_equal(val, '"Robot Super Course X"')
############# HELPERS ############### ############# HELPERS ###############
...@@ -132,19 +151,23 @@ def create_entry(key, value): ...@@ -132,19 +151,23 @@ def create_entry(key, value):
new_key_css = 'div#__new_advanced_key__ input' new_key_css = 'div#__new_advanced_key__ input'
new_key_element = css_find(new_key_css).first new_key_element = css_find(new_key_css).first
new_key_element.fill(key) 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) # For some reason have to get the instance for each command
# Have to do all this because Selenium has a bug that fill does not remove existing text # (get error that it is no longer attached to the DOM)
# Have to do all this because Selenium fill does not remove existing text
new_value_css = 'div.CodeMirror textarea' new_value_css = 'div.CodeMirror textarea'
css_find(new_value_css).last.fill("") 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._element.send_keys(Keys.DELETE, Keys.DELETE)
css_find(new_value_css).last.fill(value) css_find(new_value_css).last.fill(value)
# Add in a TAB key press because intermittently on ubuntu the
# last character of "value" above was not getting typed in
css_find(new_value_css).last._element.send_keys(Keys.TAB)
def delete_entry(index): def delete_entry(index):
""" """
Delete the nth entry where index is 0-based Delete the nth entry where index is 0-based
""" """
css = '.delete-button' css = 'a.delete-button'
assert_true(world.browser.is_element_present_by_css(css, 5)) assert_true(world.browser.is_element_present_by_css(css, 5))
delete_buttons = css_find(css) delete_buttons = css_find(css)
assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index)) assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index))
...@@ -152,8 +175,8 @@ def delete_entry(index): ...@@ -152,8 +175,8 @@ def delete_entry(index):
def assert_policy_entries(expected_keys, expected_values): def assert_policy_entries(expected_keys, expected_values):
assert_entries('.key input', expected_keys) assert_entries('.key input.policy-key', expected_keys)
assert_entries('.json', expected_values) assert_entries('textarea.json', expected_values)
def assert_entries(css, expected_values): def assert_entries(css, expected_values):
...@@ -165,16 +188,8 @@ def assert_entries(css, expected_values): ...@@ -165,16 +188,8 @@ def assert_entries(css, expected_values):
def click_save(): def click_save():
css = ".save-button" css = "a.save-button"
css_click_at(css)
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): def fill_last_field(value):
......
...@@ -3,18 +3,20 @@ from lettuce.django import django_url ...@@ -3,18 +3,20 @@ from lettuce.django import django_url
from nose.tools import assert_true from nose.tools import assert_true
from nose.tools import assert_equal from nose.tools import assert_equal
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import WebDriverException, StaleElementReferenceException
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from terrain.factories import UserFactory, RegistrationFactory, UserProfileFactory from terrain.factories import UserFactory, RegistrationFactory, UserProfileFactory
from terrain.factories import CourseFactory, GroupFactory from terrain.factories import CourseFactory, GroupFactory
import xmodule.modulestore.django from xmodule.modulestore.django import _MODULESTORES, modulestore
from xmodule.templates import update_templates
from auth.authz import get_user_by_email from auth.authz import get_user_by_email
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
########### STEP HELPERS ############## ########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$') @step('I (?:visit|access|open) the Studio homepage$')
def i_visit_the_studio_homepage(step): def i_visit_the_studio_homepage(step):
# To make this go to port 8001, put # To make this go to port 8001, put
...@@ -52,9 +54,8 @@ def i_have_opened_a_new_course(step): ...@@ -52,9 +54,8 @@ def i_have_opened_a_new_course(step):
log_into_studio() log_into_studio()
create_a_course() create_a_course()
####### HELPER FUNCTIONS ##############
####### HELPER FUNCTIONS ##############
def create_studio_user( def create_studio_user(
uname='robot', uname='robot',
email='robot+studio@edx.org', email='robot+studio@edx.org',
...@@ -83,9 +84,9 @@ def flush_xmodule_store(): ...@@ -83,9 +84,9 @@ def flush_xmodule_store():
# (though it shouldn't), do this manually # (though it shouldn't), do this manually
# from the bash shell to drop it: # from the bash shell to drop it:
# $ mongo test_xmodule --eval "db.dropDatabase()" # $ mongo test_xmodule --eval "db.dropDatabase()"
xmodule.modulestore.django._MODULESTORES = {} _MODULESTORES = {}
xmodule.modulestore.django.modulestore().collection.drop() modulestore().collection.drop()
xmodule.templates.update_templates() update_templates()
def assert_css_with_text(css, text): def assert_css_with_text(css, text):
...@@ -94,8 +95,16 @@ def assert_css_with_text(css, text): ...@@ -94,8 +95,16 @@ def assert_css_with_text(css, text):
def css_click(css): def css_click(css):
assert_true(world.browser.is_element_present_by_css(css, 5)) '''
world.browser.find_by_css(css).first.click() First try to use the regular click method,
but if clicking in the middle of an element
doesn't work it might be that it thinks some other
element is on top of it there so click in the upper left
'''
try:
css_find(css).first.click()
except WebDriverException, e:
css_click_at(css)
def css_click_at(css, x=10, y=10): def css_click_at(css, x=10, y=10):
...@@ -103,8 +112,7 @@ def css_click_at(css, x=10, y=10): ...@@ -103,8 +112,7 @@ def css_click_at(css, x=10, y=10):
A method to click at x,y coordinates of the element A method to click at x,y coordinates of the element
rather than in the center of the element rather than in the center of the element
''' '''
assert_true(world.browser.is_element_present_by_css(css, 5)) e = css_find(css).first
e = world.browser.find_by_css(css).first
e.action_chains.move_to_element_with_offset(e._element, x, y) e.action_chains.move_to_element_with_offset(e._element, x, y)
e.action_chains.click() e.action_chains.click()
e.action_chains.perform() e.action_chains.perform()
...@@ -115,11 +123,16 @@ def css_fill(css, value): ...@@ -115,11 +123,16 @@ def css_fill(css, value):
def css_find(css): def css_find(css):
def is_visible(driver):
return EC.visibility_of_element_located((By.CSS_SELECTOR,css,))
world.browser.is_element_present_by_css(css, 5)
wait_for(is_visible)
return world.browser.find_by_css(css) return world.browser.find_by_css(css)
def wait_for(func): def wait_for(func):
WebDriverWait(world.browser.driver, 10).until(func) WebDriverWait(world.browser.driver, 5).until(func)
def id_find(id): def id_find(id):
......
...@@ -26,9 +26,10 @@ Feature: Create Section ...@@ -26,9 +26,10 @@ Feature: Create Section
And I save a new section release date And I save a new section release date
Then the section release date is updated Then the section release date is updated
@skip-phantom
Scenario: Delete section Scenario: Delete section
Given I have opened a new course in Studio Given I have opened a new course in Studio
And I have added a new section And I have added a new section
When I press the "section" delete icon When I press the "section" delete icon
And I confirm the alert And I confirm the alert
Then the section does not exist Then the section does not exist
\ No newline at end of file
from lettuce import world, step from lettuce import world, step
from common import * from common import *
from nose.tools import assert_equal from nose.tools import assert_equal
from selenium.webdriver.common.keys import Keys
import time
############### ACTIONS #################### ############### ACTIONS ####################
...@@ -37,10 +39,14 @@ def i_save_a_new_section_release_date(step): ...@@ -37,10 +39,14 @@ def i_save_a_new_section_release_date(step):
date_css = 'input.start-date.date.hasDatepicker' date_css = 'input.start-date.date.hasDatepicker'
time_css = 'input.start-time.time.ui-timepicker-input' time_css = 'input.start-time.time.ui-timepicker-input'
css_fill(date_css, '12/25/2013') css_fill(date_css, '12/25/2013')
# click here to make the calendar go away # hit TAB to get to the time field
css_click(time_css) e = css_find(date_css).first
e._element.send_keys(Keys.TAB)
css_fill(time_css, '12:00am') css_fill(time_css, '12:00am')
css_click('a.save-button') e = css_find(time_css).first
e._element.send_keys(Keys.TAB)
time.sleep(float(1))
world.browser.click_link_by_text('Save')
############ ASSERTIONS ################### ############ ASSERTIONS ###################
...@@ -106,7 +112,7 @@ def the_section_release_date_picker_not_visible(step): ...@@ -106,7 +112,7 @@ def the_section_release_date_picker_not_visible(step):
def the_section_release_date_is_updated(step): def the_section_release_date_is_updated(step):
css = 'span.published-status' css = 'span.published-status'
status_text = world.browser.find_by_css(css).text status_text = world.browser.find_by_css(css).text
assert status_text == 'Will Release: 12/25/2013 at 12:00am' assert_equal(status_text,'Will Release: 12/25/2013 at 12:00am')
############ HELPER METHODS ################### ############ HELPER METHODS ###################
...@@ -120,4 +126,4 @@ def save_section_name(name): ...@@ -120,4 +126,4 @@ def save_section_name(name):
def see_my_section_on_the_courseware_page(name): def see_my_section_on_the_courseware_page(name):
section_css = 'span.section-name-span' section_css = 'span.section-name-span'
assert_css_with_text(section_css, name) assert_css_with_text(section_css, name)
\ No newline at end of file
from lettuce import world, step from lettuce import world, step
from common import *
@step('I fill in the registration form$') @step('I fill in the registration form$')
...@@ -13,10 +14,11 @@ def i_fill_in_the_registration_form(step): ...@@ -13,10 +14,11 @@ def i_fill_in_the_registration_form(step):
@step('I press the Create My Account button on the registration form$') @step('I press the Create My Account button on the registration form$')
def i_press_the_button_on_the_registration_form(step): def i_press_the_button_on_the_registration_form(step):
register_form = world.browser.find_by_css('form#register_form') submit_css = 'form#register_form button#submit'
submit_css = 'button#submit' # Workaround for click not working on ubuntu
register_form.find_by_css(submit_css).click() # for some unknown reason.
e = css_find(submit_css)
e.type(' ')
@step('I should see be on the studio home page$') @step('I should see be on the studio home page$')
def i_should_see_be_on_the_studio_home_page(step): def i_should_see_be_on_the_studio_home_page(step):
......
...@@ -21,6 +21,7 @@ Feature: Overview Toggle Section ...@@ -21,6 +21,7 @@ Feature: Overview Toggle Section
Then I see the "Collapse All Sections" link Then I see the "Collapse All Sections" link
And all sections are expanded And all sections are expanded
@skip-phantom
Scenario: Collapse link is not removed after last section of a course is deleted Scenario: Collapse link is not removed after last section of a course is deleted
Given I have a course with 1 section Given I have a course with 1 section
And I navigate to the course overview page And I navigate to the course overview page
......
...@@ -17,6 +17,7 @@ Feature: Create Subsection ...@@ -17,6 +17,7 @@ Feature: Create Subsection
And I click to edit the subsection name And I click to edit the subsection name
Then I see the complete subsection name with a quote in the editor Then I see the complete subsection name with a quote in the editor
@skip-phantom
Scenario: Delete a subsection Scenario: Delete a subsection
Given I have opened a new course section in Studio Given I have opened a new course section in Studio
And I have added a new subsection And I have added a new subsection
......
...@@ -263,7 +263,33 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -263,7 +263,33 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# note, we know the link it should be because that's what in the 'full' course in the test data # note, we know the link it should be because that's what in the 'full' course in the test data
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
def test_export_course_with_unknown_metadata(self):
ms = modulestore('direct')
cs = contentstore()
import_from_xml(ms, 'common/test/data/', ['full'])
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
root_dir = path(mkdtemp_clean())
course = ms.get_item(location)
# add a bool piece of unknown metadata so we can verify we don't throw an exception
course.metadata['new_metadata'] = True
ms.update_metadata(location, course.metadata)
print 'Exporting to tempdir = {0}'.format(root_dir)
# export out to a tempdir
bExported = False
try:
export_to_xml(ms, cs, location, root_dir, 'test_export')
bExported = True
except Exception:
pass
self.assertTrue(bExported)
class ContentStoreTest(ModuleStoreTestCase): class ContentStoreTest(ModuleStoreTestCase):
""" """
......
...@@ -62,3 +62,6 @@ AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] ...@@ -62,3 +62,6 @@ AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
DATABASES = AUTH_TOKENS['DATABASES'] DATABASES = AUTH_TOKENS['DATABASES']
MODULESTORE = AUTH_TOKENS['MODULESTORE'] MODULESTORE = AUTH_TOKENS['MODULESTORE']
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE'] CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
# Datadog for events!
DATADOG_API = AUTH_TOKENS.get("DATADOG_API")
\ No newline at end of file
...@@ -4,9 +4,6 @@ This config file runs the simplest dev environment""" ...@@ -4,9 +4,6 @@ This config file runs the simplest dev environment"""
from .common import * from .common import *
from logsettings import get_logger_config from logsettings import get_logger_config
import logging
import sys
DEBUG = True DEBUG = True
TEMPLATE_DEBUG = DEBUG TEMPLATE_DEBUG = DEBUG
LOGGING = get_logger_config(ENV_ROOT / "log", LOGGING = get_logger_config(ENV_ROOT / "log",
...@@ -107,3 +104,36 @@ CACHE_TIMEOUT = 0 ...@@ -107,3 +104,36 @@ CACHE_TIMEOUT = 0
# Dummy secret key for dev # Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
################################ DEBUG TOOLBAR #################################
INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo')
MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',)
INTERNAL_IPS = ('127.0.0.1',)
DEBUG_TOOLBAR_PANELS = (
'debug_toolbar.panels.version.VersionDebugPanel',
'debug_toolbar.panels.timer.TimerDebugPanel',
'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel',
'debug_toolbar.panels.headers.HeaderDebugPanel',
'debug_toolbar.panels.request_vars.RequestVarsDebugPanel',
'debug_toolbar.panels.sql.SQLDebugPanel',
'debug_toolbar.panels.signals.SignalDebugPanel',
'debug_toolbar.panels.logger.LoggingPanel',
# This is breaking Mongo updates-- Christina is investigating.
# 'debug_toolbar_mongo.panel.MongoDebugPanel',
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
# hit twice). So you can uncomment when you need to diagnose performance
# problems, but you shouldn't leave it on.
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
)
DEBUG_TOOLBAR_CONFIG = {
'INTERCEPT_REDIRECTS': False
}
# To see stacktraces for MongoDB queries, set this to True.
# Stacktraces slow down page loads drastically (for pages with lots of queries).
# DEBUG_TOOLBAR_MONGO_STACKTRACES = False
from dogapi import dog_http_api, dog_stats_api
from django.conf import settings
if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True)
...@@ -31,7 +31,8 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ ...@@ -31,7 +31,8 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
// because these are outside of this.$el, they can't be in the event hash // because these are outside of this.$el, they can't be in the event hash
$('.save-button').on('click', this, this.saveView); $('.save-button').on('click', this, this.saveView);
$('.cancel-button').on('click', this, this.revertView); $('.cancel-button').on('click', this, this.revertView);
this.model.on('error', this.handleValidationError, this); this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
}, },
render: function() { render: function() {
// catch potential outside call before template loaded // catch potential outside call before template loaded
...@@ -228,7 +229,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ ...@@ -228,7 +229,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
var error = {}; var error = {};
error[oldKey] = 'You have already defined "' + newKey + '" in the manual policy definitions.'; error[oldKey] = 'You have already defined "' + newKey + '" in the manual policy definitions.';
error[newKey] = "You tried to enter a duplicate of this key."; error[newKey] = "You tried to enter a duplicate of this key.";
this.model.trigger("error", this.model, error); this.model.trigger("invalid", this.model, error);
return false; return false;
} }
...@@ -244,7 +245,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ ...@@ -244,7 +245,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
// swap to the key which the map knows about // swap to the key which the map knows about
validation[oldKey] = validation[newKey]; validation[oldKey] = validation[newKey];
} }
this.model.trigger("error", this.model, validation); this.model.trigger("invalid", this.model, validation);
// abandon update // abandon update
return; return;
} }
......
...@@ -26,7 +26,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -26,7 +26,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
var dateIntrospect = new Date(); var dateIntrospect = new Date();
this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")"); this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")");
this.model.on('error', this.handleValidationError, this); this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.selectorToField = _.invert(this.fieldToSelectorMap); this.selectorToField = _.invert(this.fieldToSelectorMap);
}, },
......
...@@ -44,7 +44,8 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ ...@@ -44,7 +44,8 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
self.render(); self.render();
} }
); );
this.model.on('error', this.handleValidationError, this); this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.model.get('graders').on('remove', this.render, this); this.model.get('graders').on('remove', this.render, this);
this.model.get('graders').on('reset', this.render, this); this.model.get('graders').on('reset', this.render, this);
this.model.get('graders').on('add', this.render, this); this.model.get('graders').on('add', this.render, this);
...@@ -316,7 +317,8 @@ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({ ...@@ -316,7 +317,8 @@ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
'blur :input' : "inputUnfocus" 'blur :input' : "inputUnfocus"
}, },
initialize : function() { initialize : function() {
this.model.on('error', this.handleValidationError, this); this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.selectorToField = _.invert(this.fieldToSelectorMap); this.selectorToField = _.invert(this.fieldToSelectorMap);
this.render(); this.render();
}, },
......
...@@ -3,7 +3,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({ ...@@ -3,7 +3,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({
// decorates the fields. Needs wiring per class, but this initialization shows how // decorates the fields. Needs wiring per class, but this initialization shows how
// either have your init call this one or copy the contents // either have your init call this one or copy the contents
initialize : function() { initialize : function() {
this.model.on('error', this.handleValidationError, this); this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.selectorToField = _.invert(this.fieldToSelectorMap); this.selectorToField = _.invert(this.fieldToSelectorMap);
}, },
...@@ -18,20 +19,11 @@ CMS.Views.ValidatingView = Backbone.View.extend({ ...@@ -18,20 +19,11 @@ CMS.Views.ValidatingView = Backbone.View.extend({
// which may be the subjects of validation errors // which may be the subjects of validation errors
}, },
_cacheValidationErrors : [], _cacheValidationErrors : [],
handleValidationError : function(model, error) { handleValidationError : function(model, error) {
// error triggered either by validation or server error
// error is object w/ fields and error strings // error is object w/ fields and error strings
for (var field in error) { for (var field in error) {
var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); var ele = this.$el.find('#' + this.fieldToSelectorMap[field]);
if (ele.length === 0) {
// check if it might a server error: note a typo in the field name
// or failure to put in a map may cause this to muffle validation errors
if (_.has(error, 'error') && _.has(error, 'responseText')) {
CMS.ServerError(model, error);
return;
}
else continue;
}
this._cacheValidationErrors.push(ele); this._cacheValidationErrors.push(ele);
if ($(ele).is('div')) { if ($(ele).is('div')) {
// put error on the contained inputs // put error on the contained inputs
......
from django.conf import settings from django.conf import settings
from django.conf.urls import patterns, include, url from django.conf.urls import patterns, include, url
from . import one_time_startup
# Uncomment the next two lines to enable the admin: # Uncomment the next two lines to enable the admin:
# from django.contrib import admin # from django.contrib import admin
......
...@@ -2,8 +2,9 @@ import json ...@@ -2,8 +2,9 @@ import json
from datetime import datetime from datetime import datetime
from django.http import HttpResponse from django.http import HttpResponse
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from dogapi import dog_stats_api
@dog_stats_api.timed('edxapp.heartbeat')
def heartbeat(request): def heartbeat(request):
""" """
Simple view that a loadbalancer can check to verify that the app is up Simple view that a loadbalancer can check to verify that the app is up
......
...@@ -84,12 +84,19 @@ def replace_static_urls(text, data_directory, course_namespace=None): ...@@ -84,12 +84,19 @@ def replace_static_urls(text, data_directory, course_namespace=None):
if rest.endswith('?raw'): if rest.endswith('?raw'):
return original 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)
# In debug mode, if we can find the url as is, # In debug mode, if we can find the url as is,
elif settings.DEBUG and finders.find(rest, True): if settings.DEBUG and finders.find(rest, True):
return original return original
# if we're running with a MongoBacked store course_namespace is not None, then use studio style urls
elif course_namespace is not None and not isinstance(modulestore(), XMLModuleStore):
# first look in the static file pipeline and see if we are trying to reference
# a piece of static content which is in the mitx repo (e.g. JS associated with an xmodule)
if staticfiles_storage.exists(rest):
url = staticfiles_storage.url(rest)
else:
# if not, then assume it's courseware specific content and then look in the
# Mongo-backed database
url = StaticContent.convert_legacy_static_url(rest, course_namespace)
# Otherwise, look the file up in staticfiles_storage, and append the data directory if needed # Otherwise, look the file up in staticfiles_storage, and append the data directory if needed
else: else:
course_path = "/".join((data_directory, rest)) course_path = "/".join((data_directory, rest))
......
...@@ -10,6 +10,7 @@ import paramiko ...@@ -10,6 +10,7 @@ import paramiko
import boto import boto
dog_http_api.api_key = settings.DATADOG_API dog_http_api.api_key = settings.DATADOG_API
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True)
class Command(BaseCommand): class Command(BaseCommand):
......
...@@ -13,6 +13,7 @@ from django.core.management import call_command ...@@ -13,6 +13,7 @@ from django.core.management import call_command
def initial_setup(server): def initial_setup(server):
# Launch the browser app (choose one of these below) # Launch the browser app (choose one of these below)
world.browser = Browser('chrome') world.browser = Browser('chrome')
# world.browser = Browser('phantomjs')
# world.browser = Browser('firefox') # world.browser = Browser('firefox')
......
...@@ -29,6 +29,7 @@ import sys ...@@ -29,6 +29,7 @@ import sys
from lxml import etree from lxml import etree
from xml.sax.saxutils import unescape from xml.sax.saxutils import unescape
from copy import deepcopy
import chem import chem
import chem.chemcalc import chem.chemcalc
...@@ -497,11 +498,10 @@ class LoncapaProblem(object): ...@@ -497,11 +498,10 @@ class LoncapaProblem(object):
Used by get_html. Used by get_html.
''' '''
if (problemtree.tag == 'script' and problemtree.get('type') if (problemtree.tag == 'script' and problemtree.get('type')
and 'javascript' in problemtree.get('type')): and 'javascript' in problemtree.get('type')):
# leave javascript intact. # leave javascript intact.
return problemtree return deepcopy(problemtree)
if problemtree.tag in html_problem_semantics: if problemtree.tag in html_problem_semantics:
return return
......
...@@ -95,7 +95,7 @@ class CorrectMap(object): ...@@ -95,7 +95,7 @@ class CorrectMap(object):
def is_correct(self, answer_id): def is_correct(self, answer_id):
if answer_id in self.cmap: if answer_id in self.cmap:
return self.cmap[answer_id]['correctness'] == 'correct' return self.cmap[answer_id]['correctness'] in ['correct', 'partially-correct']
return None return None
def is_queued(self, answer_id): def is_queued(self, answer_id):
...@@ -111,15 +111,14 @@ class CorrectMap(object): ...@@ -111,15 +111,14 @@ class CorrectMap(object):
return None return None
def get_npoints(self, answer_id): def get_npoints(self, answer_id):
""" Return the number of points for an answer: """Return the number of points for an answer, used for partial credit."""
If the answer is correct, return the assigned npoints = self.get_property(answer_id, 'npoints')
number of points (default: 1 point) if npoints is not None:
Otherwise, return 0 points """ return npoints
if self.is_correct(answer_id): elif self.is_correct(answer_id):
npoints = self.get_property(answer_id, 'npoints') return 1
return npoints if npoints is not None else 1 # if not correct and no points have been assigned, return 0
else: return 0
return 0
def set_property(self, answer_id, property, value): def set_property(self, answer_id, property, value):
if answer_id in self.cmap: if answer_id in self.cmap:
......
...@@ -45,8 +45,10 @@ import re ...@@ -45,8 +45,10 @@ import re
import shlex # for splitting quoted strings import shlex # for splitting quoted strings
import sys import sys
import os import os
import pyparsing
from registry import TagRegistry from registry import TagRegistry
from capa.chem import chemcalc
log = logging.getLogger('mitx.' + __name__) log = logging.getLogger('mitx.' + __name__)
...@@ -752,6 +754,45 @@ class ChemicalEquationInput(InputTypeBase): ...@@ -752,6 +754,45 @@ class ChemicalEquationInput(InputTypeBase):
""" """
return {'previewer': '/static/js/capa/chemical_equation_preview.js', } return {'previewer': '/static/js/capa/chemical_equation_preview.js', }
def handle_ajax(self, dispatch, get):
'''
Since we only have chemcalc preview this input, check to see if it
matches the corresponding dispatch and send it through if it does
'''
if dispatch == 'preview_chemcalc':
return self.preview_chemcalc(get)
return {}
def preview_chemcalc(self, get):
"""
Render an html preview of a chemical formula or equation. get should
contain a key 'formula' and value 'some formula string'.
Returns a json dictionary:
{
'preview' : 'the-preview-html' or ''
'error' : 'the-error' or ''
}
"""
result = {'preview': '',
'error': ''}
formula = get['formula']
if formula is None:
result['error'] = "No formula specified."
return result
try:
result['preview'] = chemcalc.render_to_html(formula)
except pyparsing.ParseException as p:
result['error'] = "Couldn't parse formula: {0}".format(p)
except Exception:
# this is unexpected, so log
log.warning("Error while previewing chemical formula", exc_info=True)
result['error'] = "Error while rendering preview"
return result
registry.register(ChemicalEquationInput) registry.register(ChemicalEquationInput)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
...@@ -921,33 +962,142 @@ registry.register(DesignProtein2dInput) ...@@ -921,33 +962,142 @@ registry.register(DesignProtein2dInput)
class EditAGeneInput(InputTypeBase): class EditAGeneInput(InputTypeBase):
""" """
An input type for editing a gene. Integrates with the genex java applet. An input type for editing a gene. Integrates with the genex java applet.
Example: Example:
<editagene width="800" hight="500" dna_sequence="ETAAGGCTATAACCGA" /> <editagene width="800" hight="500" dna_sequence="ETAAGGCTATAACCGA" />
""" """
template = "editageneinput.html" template = "editageneinput.html"
tags = ['editageneinput'] tags = ['editageneinput']
@classmethod @classmethod
def get_attributes(cls): def get_attributes(cls):
""" """
Note: width, hight, and dna_sequencee are required. Note: width, height, and dna_sequencee are required.
""" """
return [Attribute('width'), return [Attribute('width'),
Attribute('height'), Attribute('height'),
Attribute('dna_sequence') Attribute('dna_sequence'),
Attribute('genex_problem_number')
] ]
def _extra_context(self): def _extra_context(self):
""" """
""" """
context = { context = {
'applet_loader': '/static/js/capa/edit-a-gene.js', 'applet_loader': '/static/js/capa/edit-a-gene.js',
} }
return context return context
registry.register(EditAGeneInput) registry.register(EditAGeneInput)
#---------------------------------------------------------------------
class AnnotationInput(InputTypeBase):
"""
Input type for annotations: students can enter some notes or other text
(currently ungraded), and then choose from a set of tags/optoins, which are graded.
Example:
<annotationinput>
<title>Annotation Exercise</title>
<text>
They are the ones who, at the public assembly, had put savage derangement [ate] into my thinking
[phrenes] |89 on that day when I myself deprived Achilles of his honorific portion [geras]
</text>
<comment>Agamemnon says that ate or 'derangement' was the cause of his actions: why could Zeus say the same thing?</comment>
<comment_prompt>Type a commentary below:</comment_prompt>
<tag_prompt>Select one tag:</tag_prompt>
<options>
<option choice="correct">ate - both a cause and an effect</option>
<option choice="incorrect">ate - a cause</option>
<option choice="partially-correct">ate - an effect</option>
</options>
</annotationinput>
# TODO: allow ordering to be randomized
"""
template = "annotationinput.html"
tags = ['annotationinput']
def setup(self):
xml = self.xml
self.debug = False # set to True to display extra debug info with input
self.return_to_annotation = True # return only works in conjunction with annotatable xmodule
self.title = xml.findtext('./title', 'Annotation Exercise')
self.text = xml.findtext('./text')
self.comment = xml.findtext('./comment')
self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:')
self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:')
self.options = self._find_options()
# Need to provide a value that JSON can parse if there is no
# student-supplied value yet.
if self.value == '':
self.value = 'null'
self._validate_options()
def _find_options(self):
''' Returns an array of dicts where each dict represents an option. '''
elements = self.xml.findall('./options/option')
return [{
'id': index,
'description': option.text,
'choice': option.get('choice')
} for (index, option) in enumerate(elements) ]
def _validate_options(self):
''' Raises a ValueError if the choice attribute is missing or invalid. '''
valid_choices = ('correct', 'partially-correct', 'incorrect')
for option in self.options:
choice = option['choice']
if choice is None:
raise ValueError('Missing required choice attribute.')
elif choice not in valid_choices:
raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format(choice, ', '.join(valid_choices)))
def _unpack(self, json_value):
''' Unpacks the json input state into a dict. '''
d = json.loads(json_value)
if type(d) != dict:
d = {}
comment_value = d.get('comment', '')
if not isinstance(comment_value, basestring):
comment_value = ''
options_value = d.get('options', [])
if not isinstance(options_value, list):
options_value = []
return {
'options_value': options_value,
'has_options_value': len(options_value) > 0, # for convenience
'comment_value': comment_value,
}
def _extra_context(self):
extra_context = {
'title': self.title,
'text': self.text,
'comment': self.comment,
'comment_prompt': self.comment_prompt,
'tag_prompt': self.tag_prompt,
'options': self.options,
'return_to_annotation': self.return_to_annotation,
'debug': self.debug
}
extra_context.update(self._unpack(self.value))
return extra_context
registry.register(AnnotationInput)
...@@ -911,7 +911,8 @@ def sympy_check2(): ...@@ -911,7 +911,8 @@ def sympy_check2():
allowed_inputfields = ['textline', 'textbox', 'crystallography', allowed_inputfields = ['textline', 'textbox', 'crystallography',
'chemicalequationinput', 'vsepr_input', 'chemicalequationinput', 'vsepr_input',
'drag_and_drop_input', 'editamoleculeinput', 'drag_and_drop_input', 'editamoleculeinput',
'designprotein2dinput', 'editageneinput'] 'designprotein2dinput', 'editageneinput',
'annotationinput']
def setup_response(self): def setup_response(self):
xml = self.xml xml = self.xml
...@@ -1943,6 +1944,117 @@ class ImageResponse(LoncapaResponse): ...@@ -1943,6 +1944,117 @@ class ImageResponse(LoncapaResponse):
dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements])) dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements]))
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
class AnnotationResponse(LoncapaResponse):
'''
Checking of annotation responses.
The response contains both a comment (student commentary) and an option (student tag).
Only the tag is currently graded. Answers may be incorrect, partially correct, or correct.
'''
response_tag = 'annotationresponse'
allowed_inputfields = ['annotationinput']
max_inputfields = 1
default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2 }
def setup_response(self):
xml = self.xml
self.scoring_map = self._get_scoring_map()
self.answer_map = self._get_answer_map()
self.maxpoints = self._get_max_points()
def get_score(self, student_answers):
''' Returns a CorrectMap for the student answer, which may include
partially correct answers.'''
student_answer = student_answers[self.answer_id]
student_option = self._get_submitted_option_id(student_answer)
scoring = self.scoring_map[self.answer_id]
is_valid = student_option is not None and student_option in scoring.keys()
(correctness, points) = ('incorrect', None)
if is_valid:
correctness = scoring[student_option]['correctness']
points = scoring[student_option]['points']
return CorrectMap(self.answer_id, correctness=correctness, npoints=points)
def get_answers(self):
return self.answer_map
def _get_scoring_map(self):
''' Returns a dict of option->scoring for each input. '''
scoring = self.default_scoring
choices = dict([(choice,choice) for choice in scoring])
scoring_map = {}
for inputfield in self.inputfields:
option_scoring = dict([(option['id'], {
'correctness': choices.get(option['choice']),
'points': scoring.get(option['choice'])
}) for option in self._find_options(inputfield) ])
scoring_map[inputfield.get('id')] = option_scoring
return scoring_map
def _get_answer_map(self):
''' Returns a dict of answers for each input.'''
answer_map = {}
for inputfield in self.inputfields:
correct_option = self._find_option_with_choice(inputfield, 'correct')
if correct_option is not None:
answer_map[inputfield.get('id')] = correct_option.get('description')
return answer_map
def _get_max_points(self):
''' Returns a dict of the max points for each input: input id -> maxpoints. '''
scoring = self.default_scoring
correct_points = scoring.get('correct')
return dict([(inputfield.get('id'), correct_points) for inputfield in self.inputfields])
def _find_options(self, inputfield):
''' Returns an array of dicts where each dict represents an option. '''
elements = inputfield.findall('./options/option')
return [{
'id': index,
'description': option.text,
'choice': option.get('choice')
} for (index, option) in enumerate(elements) ]
def _find_option_with_choice(self, inputfield, choice):
''' Returns the option with the given choice value, otherwise None. '''
for option in self._find_options(inputfield):
if option['choice'] == choice:
return option
def _unpack(self, json_value):
''' Unpacks a student response value submitted as JSON.'''
d = json.loads(json_value)
if type(d) != dict:
d = {}
comment_value = d.get('comment', '')
if not isinstance(d, basestring):
comment_value = ''
options_value = d.get('options', [])
if not isinstance(options_value, list):
options_value = []
return {
'options_value': options_value,
'comment_value': comment_value
}
def _get_submitted_option_id(self, student_answer):
''' Return the single option that was selected, otherwise None.'''
submitted = self._unpack(student_answer)
option_ids = submitted['options_value']
if len(option_ids) == 1:
return option_ids[0]
return None
#-----------------------------------------------------------------------------
# TEMPORARY: List of all response subclasses # TEMPORARY: List of all response subclasses
# FIXME: To be replaced by auto-registration # FIXME: To be replaced by auto-registration
...@@ -1959,4 +2071,5 @@ __all__ = [CodeResponse, ...@@ -1959,4 +2071,5 @@ __all__ = [CodeResponse,
ChoiceResponse, ChoiceResponse,
MultipleChoiceResponse, MultipleChoiceResponse,
TrueFalseResponse, TrueFalseResponse,
JavascriptResponse] JavascriptResponse,
AnnotationResponse]
<form class="annotation-input">
<div class="script_placeholder" data-src="/static/js/capa/annotationinput.js"/>
<div class="annotation-header">
${title}
% if return_to_annotation:
<a class="annotation-return" href="javascript:void(0)">Return to Annotation</a><br/>
% endif
</div>
<div class="annotation-body">
<div class="block block-highlight">${text}</div>
<div class="block block-comment">${comment}</div>
<div class="block">${comment_prompt}</div>
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment">${comment_value|h}</textarea>
<div class="block">${tag_prompt}</div>
<ul class="tags">
% for option in options:
<li>
% if has_options_value:
% if all([c == 'correct' for c in option['choice'], status]):
<span class="tag-status correct" id="status_${id}"></span>
% elif all([c == 'partially-correct' for c in option['choice'], status]):
<span class="tag-status partially-correct" id="status_${id}"></span>
% elif all([c == 'incorrect' for c in option['choice'], status]):
<span class="tag-status incorrect" id="status_${id}"></span>
% endif
% endif
<span class="tag
% if option['id'] in options_value:
selected
% endif
" data-id="${option['id']}">
${option['description']}
</span>
</li>
% endfor
</ul>
% if debug:
<div class="debug-value">
Rendered with value:<br/>
<pre>${value|h}</pre>
Current input value:<br/>
<input type="text" class="value" name="input_${id}" id="input_${id}" value="${value|h}" />
</div>
% else:
<input type="hidden" class="value" name="input_${id}" id="input_${id}" value="${value|h}" />
% endif
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
% elif status == 'incorrect' and not has_options_value:
<span class="incorrect" id="status_${id}"></span>
% endif
<p id="answer_${id}" class="answer answer-annotation"></p>
</div>
</form>
% if msg:
<span class="message">${msg|n}</span>
% endif
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<div class="incorrect" id="status_${id}"> <div class="incorrect" id="status_${id}">
% endif % endif
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}" <input type="text" name="input_${id}" id="input_${id}" data-input-id="${id}" value="${value|h}"
% if size: % if size:
size="${size}" size="${size}"
% endif % endif
......
<form class="choicegroup capa_inputtype" id="inputtype_${id}"> <form class="choicegroup capa_inputtype" id="inputtype_${id}">
<div class="indicator_container">
<fieldset> % if input_type == 'checkbox' or not value:
% for choice_id, choice_description in choices: % if status == 'unsubmitted':
<label for="input_${id}_${choice_id}"> <span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'correct':
% if choice_id in value: <span class="correct" id="status_${id}"></span>
<span class="indicator_container"> % elif status == 'incorrect':
% if status == 'unsubmitted': <span class="incorrect" id="status_${id}"></span>
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span> % elif status == 'incomplete':
% elif status == 'correct': <span class="incorrect" id="status_${id}"></span>
<span class="correct" id="status_${id}"></span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
% endif
</span>
% else:
<span class="indicator_container">&#160;</span>
% endif % endif
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
% if choice_id in value:
checked="true"
% endif % endif
/> </div>
${choice_description} <fieldset>
</label> % for choice_id, choice_description in choices:
<label for="input_${id}_${choice_id}"
% if input_type == 'radio' and choice_id in value:
<%
if status == 'correct':
correctness = 'correct'
elif status == 'incorrect':
correctness = 'incorrect'
else:
correctness = None
%>
% if correctness:
class="choicegroup_${correctness}"
% endif
% endif
>
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
% if choice_id in value:
checked="true"
% endif
% endfor /> ${choice_description} </label>
<span id="answer_${id}"></span> % endfor
</fieldset> <span id="answer_${id}"></span>
</fieldset>
</form> </form>
<section id="editageneinput_${id}" class="editageneinput"> <section id="editageneinput_${id}" class="editageneinput">
<div class="script_placeholder" data-src="/static/js/capa/genex/genex.nocache.js?raw"/>
<div class="script_placeholder" data-src="${applet_loader}"/> <div class="script_placeholder" data-src="${applet_loader}"/>
% if status == 'unsubmitted': % if status == 'unsubmitted':
...@@ -8,16 +9,12 @@ ...@@ -8,16 +9,12 @@
% elif status == 'incorrect': % elif status == 'incorrect':
<div class="incorrect" id="status_${id}"> <div class="incorrect" id="status_${id}">
% elif status == 'incomplete': % elif status == 'incomplete':
<div class="incorrect" id="status_${id}"> <div class="incomplete" id="status_${id}">
% endif % endif
<object type="application/x-java-applet" id="applet_${id}" class="applet" width="${width}" height="${height}"> <div id="genex_container"></div>
<param name="archive" value="/static/applets/capa/genex.jar" /> <input type="hidden" name="dna_sequence" id="dna_sequence" value ="${dna_sequence}"></input>
<param name="code" value="GX.GenexApplet.class" /> <input type="hidden" name="genex_problem_number" id="genex_problem_number" value ="${genex_problem_number}"></input>
<param name="DNA_SEQUENCE" value="${dna_sequence}" />
Applet failed to run. No Java plug-in was found.
</object>
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/> <input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
<p class="status"> <p class="status">
...@@ -37,3 +34,4 @@ ...@@ -37,3 +34,4 @@
</div> </div>
% endif % endif
</section> </section>
...@@ -666,3 +666,36 @@ class StringResponseXMLFactory(ResponseXMLFactory): ...@@ -666,3 +666,36 @@ class StringResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs): def create_input_element(self, **kwargs):
return ResponseXMLFactory.textline_input_xml(**kwargs) return ResponseXMLFactory.textline_input_xml(**kwargs)
class AnnotationResponseXMLFactory(ResponseXMLFactory):
""" Factory for creating <annotationresponse> XML trees """
def create_response_element(self, **kwargs):
""" Create a <annotationresponse> element """
return etree.Element("annotationresponse")
def create_input_element(self, **kwargs):
""" Create a <annotationinput> element."""
input_element = etree.Element("annotationinput")
text_children = [
{'tag': 'title', 'text': kwargs.get('title', 'super cool annotation') },
{'tag': 'text', 'text': kwargs.get('text', 'texty text') },
{'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah') },
{'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below') },
{'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag') }
]
for child in text_children:
etree.SubElement(input_element, child['tag']).text = child['text']
default_options = [('green', 'correct'),('eggs', 'incorrect'),('ham', 'partially-correct')]
options = kwargs.get('options', default_options)
options_element = etree.SubElement(input_element, 'options')
for (description, correctness) in options:
option_element = etree.SubElement(options_element, 'option', {'choice': correctness})
option_element.text = description
return input_element
...@@ -91,12 +91,12 @@ class CorrectMapTest(unittest.TestCase): ...@@ -91,12 +91,12 @@ class CorrectMapTest(unittest.TestCase):
npoints=0) npoints=0)
# Assert that we get the expected points # Assert that we get the expected points
# If points assigned and correct --> npoints # If points assigned --> npoints
# If no points assigned and correct --> 1 point # If no points assigned and correct --> 1 point
# Otherwise --> 0 points # If no points assigned and incorrect --> 0 points
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5) self.assertEqual(self.cmap.get_npoints('1_2_1'), 5)
self.assertEqual(self.cmap.get_npoints('2_2_1'), 1) self.assertEqual(self.cmap.get_npoints('2_2_1'), 1)
self.assertEqual(self.cmap.get_npoints('3_2_1'), 0) self.assertEqual(self.cmap.get_npoints('3_2_1'), 5)
self.assertEqual(self.cmap.get_npoints('4_2_1'), 0) self.assertEqual(self.cmap.get_npoints('4_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('5_2_1'), 0) self.assertEqual(self.cmap.get_npoints('5_2_1'), 0)
......
...@@ -3,6 +3,7 @@ from lxml import etree ...@@ -3,6 +3,7 @@ from lxml import etree
import os import os
import textwrap import textwrap
import json import json
import mock import mock
from capa.capa_problem import LoncapaProblem from capa.capa_problem import LoncapaProblem
...@@ -11,6 +12,20 @@ from . import test_system ...@@ -11,6 +12,20 @@ from . import test_system
class CapaHtmlRenderTest(unittest.TestCase): class CapaHtmlRenderTest(unittest.TestCase):
def test_blank_problem(self):
"""
It's important that blank problems don't break, since that's
what you start with in studio.
"""
xml_str = "<problem> </problem>"
# Create the problem
problem = LoncapaProblem(xml_str, '1', system=test_system)
# Render the HTML
rendered_html = etree.XML(problem.get_html())
# expect that we made it here without blowing up
def test_include_html(self): def test_include_html(self):
# Create a test file to include # Create a test file to include
self._create_test_file('test_include.xml', self._create_test_file('test_include.xml',
...@@ -25,7 +40,7 @@ class CapaHtmlRenderTest(unittest.TestCase): ...@@ -25,7 +40,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
# Create the problem # Create the problem
problem = LoncapaProblem(xml_str, '1', system=test_system) problem = LoncapaProblem(xml_str, '1', system=test_system)
# Render the HTML # Render the HTML
rendered_html = etree.XML(problem.get_html()) rendered_html = etree.XML(problem.get_html())
...@@ -35,6 +50,8 @@ class CapaHtmlRenderTest(unittest.TestCase): ...@@ -35,6 +50,8 @@ class CapaHtmlRenderTest(unittest.TestCase):
self.assertEqual(test_element.text, "Test include") self.assertEqual(test_element.text, "Test include")
def test_process_outtext(self): def test_process_outtext(self):
# Generate some XML with <startouttext /> and <endouttext /> # Generate some XML with <startouttext /> and <endouttext />
xml_str = textwrap.dedent(""" xml_str = textwrap.dedent("""
...@@ -45,7 +62,7 @@ class CapaHtmlRenderTest(unittest.TestCase): ...@@ -45,7 +62,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
# Create the problem # Create the problem
problem = LoncapaProblem(xml_str, '1', system=test_system) problem = LoncapaProblem(xml_str, '1', system=test_system)
# Render the HTML # Render the HTML
rendered_html = etree.XML(problem.get_html()) rendered_html = etree.XML(problem.get_html())
...@@ -64,7 +81,7 @@ class CapaHtmlRenderTest(unittest.TestCase): ...@@ -64,7 +81,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
# Create the problem # Create the problem
problem = LoncapaProblem(xml_str, '1', system=test_system) problem = LoncapaProblem(xml_str, '1', system=test_system)
# Render the HTML # Render the HTML
rendered_html = etree.XML(problem.get_html()) rendered_html = etree.XML(problem.get_html())
...@@ -72,6 +89,25 @@ class CapaHtmlRenderTest(unittest.TestCase): ...@@ -72,6 +89,25 @@ class CapaHtmlRenderTest(unittest.TestCase):
script_element = rendered_html.find('script') script_element = rendered_html.find('script')
self.assertEqual(None, script_element) self.assertEqual(None, script_element)
def test_render_javascript(self):
# Generate some XML with a <script> tag
xml_str = textwrap.dedent("""
<problem>
<script type="text/javascript">function(){}</script>
</problem>
""")
# Create the problem
problem = LoncapaProblem(xml_str, '1', system=test_system)
# Render the HTML
rendered_html = etree.XML(problem.get_html())
# expect the javascript is still present in the rendered html
self.assertTrue("<script type=\"text/javascript\">function(){}</script>" in etree.tostring(rendered_html))
def test_render_response_xml(self): def test_render_response_xml(self):
# Generate some XML for a string response # Generate some XML for a string response
kwargs = {'question_text': "Test question", kwargs = {'question_text': "Test question",
...@@ -99,11 +135,11 @@ class CapaHtmlRenderTest(unittest.TestCase): ...@@ -99,11 +135,11 @@ class CapaHtmlRenderTest(unittest.TestCase):
response_element = rendered_html.find("span") response_element = rendered_html.find("span")
self.assertEqual(response_element.tag, "span") self.assertEqual(response_element.tag, "span")
# Expect that the response <span> # Expect that the response <span>
# that contains a <div> for the textline # that contains a <div> for the textline
textline_element = response_element.find("div") textline_element = response_element.find("div")
self.assertEqual(textline_element.text, 'Input Template Render') self.assertEqual(textline_element.text, 'Input Template Render')
# Expect a child <div> for the solution # Expect a child <div> for the solution
# with the rendered template # with the rendered template
solution_element = rendered_html.find("div") solution_element = rendered_html.find("div")
...@@ -112,14 +148,14 @@ class CapaHtmlRenderTest(unittest.TestCase): ...@@ -112,14 +148,14 @@ class CapaHtmlRenderTest(unittest.TestCase):
# Expect that the template renderer was called with the correct # Expect that the template renderer was called with the correct
# arguments, once for the textline input and once for # arguments, once for the textline input and once for
# the solution # the solution
expected_textline_context = {'status': 'unsubmitted', expected_textline_context = {'status': 'unsubmitted',
'value': '', 'value': '',
'preprocessor': None, 'preprocessor': None,
'msg': '', 'msg': '',
'inline': False, 'inline': False,
'hidden': False, 'hidden': False,
'do_math': False, 'do_math': False,
'id': '1_2_1', 'id': '1_2_1',
'size': None} 'size': None}
expected_solution_context = {'id': '1_solution_1'} expected_solution_context = {'id': '1_solution_1'}
...@@ -148,7 +184,7 @@ class CapaHtmlRenderTest(unittest.TestCase): ...@@ -148,7 +184,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
# Create the problem and render the html # Create the problem and render the html
problem = LoncapaProblem(xml_str, '1', system=test_system) problem = LoncapaProblem(xml_str, '1', system=test_system)
# Grade the problem # Grade the problem
correctmap = problem.grade_answers({'1_2_1': 'test'}) correctmap = problem.grade_answers({'1_2_1': 'test'})
......
...@@ -482,27 +482,43 @@ class ChemicalEquationTest(unittest.TestCase): ...@@ -482,27 +482,43 @@ class ChemicalEquationTest(unittest.TestCase):
''' '''
Check that chemical equation inputs work. Check that chemical equation inputs work.
''' '''
def setUp(self):
def test_rendering(self): self.size = "42"
size = "42" xml_str = """<chemicalequationinput id="prob_1_2" size="{size}"/>""".format(size=self.size)
xml_str = """<chemicalequationinput id="prob_1_2" size="{size}"/>""".format(size=size)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
state = {'value': 'H2OYeah', } state = {'value': 'H2OYeah', }
the_input = lookup_tag('chemicalequationinput')(test_system, element, state) self.the_input = lookup_tag('chemicalequationinput')(test_system, element, state)
context = the_input._get_render_context()
def test_rendering(self):
''' Verify that the render context matches the expected render context'''
context = self.the_input._get_render_context()
expected = {'id': 'prob_1_2', expected = {'id': 'prob_1_2',
'value': 'H2OYeah', 'value': 'H2OYeah',
'status': 'unanswered', 'status': 'unanswered',
'msg': '', 'msg': '',
'size': size, 'size': self.size,
'previewer': '/static/js/capa/chemical_equation_preview.js', 'previewer': '/static/js/capa/chemical_equation_preview.js',
} }
self.assertEqual(context, expected) self.assertEqual(context, expected)
def test_chemcalc_ajax_sucess(self):
''' Verify that using the correct dispatch and valid data produces a valid response'''
data = {'formula': "H"}
response = self.the_input.handle_ajax("preview_chemcalc", data)
self.assertTrue('preview' in response)
self.assertNotEqual(response['preview'], '')
self.assertEqual(response['error'], "")
class DragAndDropTest(unittest.TestCase): class DragAndDropTest(unittest.TestCase):
''' '''
...@@ -570,3 +586,65 @@ class DragAndDropTest(unittest.TestCase): ...@@ -570,3 +586,65 @@ class DragAndDropTest(unittest.TestCase):
context.pop('drag_and_drop_json') context.pop('drag_and_drop_json')
expected.pop('drag_and_drop_json') expected.pop('drag_and_drop_json')
self.assertEqual(context, expected) self.assertEqual(context, expected)
class AnnotationInputTest(unittest.TestCase):
'''
Make sure option inputs work
'''
def test_rendering(self):
xml_str = '''
<annotationinput>
<title>foo</title>
<text>bar</text>
<comment>my comment</comment>
<comment_prompt>type a commentary</comment_prompt>
<tag_prompt>select a tag</tag_prompt>
<options>
<option choice="correct">x</option>
<option choice="incorrect">y</option>
<option choice="partially-correct">z</option>
</options>
</annotationinput>
'''
element = etree.fromstring(xml_str)
value = {"comment": "blah blah", "options": [1]}
json_value = json.dumps(value)
state = {
'value': json_value,
'id': 'annotation_input',
'status': 'answered'
}
tag = 'annotationinput'
the_input = lookup_tag(tag)(test_system, element, state)
context = the_input._get_render_context()
expected = {
'id': 'annotation_input',
'value': value,
'status': 'answered',
'msg': '',
'title': 'foo',
'text': 'bar',
'comment': 'my comment',
'comment_prompt': 'type a commentary',
'tag_prompt': 'select a tag',
'options': [
{'id': 0, 'description': 'x', 'choice': 'correct'},
{'id': 1, 'description': 'y', 'choice': 'incorrect'},
{'id': 2, 'description': 'z', 'choice': 'partially-correct'}
],
'value': json_value,
'options_value': value['options'],
'has_options_value': len(value['options']) > 0,
'comment_value': value['comment'],
'debug': False,
'return_to_annotation': True
}
self.maxDiff = None
self.assertDictEqual(context, expected)
...@@ -906,3 +906,40 @@ class SchematicResponseTest(ResponseTest): ...@@ -906,3 +906,40 @@ class SchematicResponseTest(ResponseTest):
# (That is, our script verifies that the context # (That is, our script verifies that the context
# is what we expect) # is what we expect)
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
class AnnotationResponseTest(ResponseTest):
from response_xml_factory import AnnotationResponseXMLFactory
xml_factory_class = AnnotationResponseXMLFactory
def test_grade(self):
(correct, partially, incorrect) = ('correct', 'partially-correct', 'incorrect')
answer_id = '1_2_1'
options = (('x', correct),('y', partially),('z', incorrect))
make_answer = lambda option_ids: {answer_id: json.dumps({'options': option_ids })}
tests = [
{'correctness': correct, 'points': 2,'answers': make_answer([0]) },
{'correctness': partially, 'points': 1, 'answers': make_answer([1]) },
{'correctness': incorrect, 'points': 0, 'answers': make_answer([2]) },
{'correctness': incorrect, 'points': 0, 'answers': make_answer([0,1,2]) },
{'correctness': incorrect, 'points': 0, 'answers': make_answer([]) },
{'correctness': incorrect, 'points': 0, 'answers': make_answer('') },
{'correctness': incorrect, 'points': 0, 'answers': make_answer(None) },
{'correctness': incorrect, 'points': 0, 'answers': {answer_id: 'null' } },
]
for (index, test) in enumerate(tests):
expected_correctness = test['correctness']
expected_points = test['points']
answers = test['answers']
problem = self.build_problem(options=options)
correct_map = problem.grade_answers(answers)
actual_correctness = correct_map.get_correctness(answer_id)
actual_points = correct_map.get_npoints(answer_id)
self.assertEqual(expected_correctness, actual_correctness,
msg="%s should be marked %s" % (answer_id, expected_correctness))
self.assertEqual(expected_points, actual_points,
msg="%s should have %d points" % (answer_id, expected_points))
...@@ -46,6 +46,7 @@ setup( ...@@ -46,6 +46,7 @@ setup(
"custom_tag_template = xmodule.raw_module:RawDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor",
"about = xmodule.html_module:AboutDescriptor", "about = xmodule.html_module:AboutDescriptor",
"graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor",
"annotatable = xmodule.annotatable_module:AnnotatableDescriptor",
"foldit = xmodule.foldit_module:FolditDescriptor", "foldit = xmodule.foldit_module:FolditDescriptor",
] ]
} }
......
import logging
from lxml import etree
from pkg_resources import resource_string, resource_listdir
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
log = logging.getLogger(__name__)
class AnnotatableModule(XModule):
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/html/display.coffee'),
resource_string(__name__, 'js/src/annotatable/display.coffee')],
'js': []
}
js_module_name = "Annotatable"
css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]}
icon_class = 'annotatable'
def _get_annotation_class_attr(self, index, el):
""" Returns a dict with the CSS class attribute to set on the annotation
and an XML key to delete from the element.
"""
attr = {}
cls = ['annotatable-span', 'highlight']
highlight_key = 'highlight'
color = el.get(highlight_key)
if color is not None:
if color in self.highlight_colors:
cls.append('highlight-'+color)
attr['_delete'] = highlight_key
attr['value'] = ' '.join(cls)
return { 'class' : attr }
def _get_annotation_data_attr(self, index, el):
""" Returns a dict in which the keys are the HTML data attributes
to set on the annotation element. Each data attribute has a
corresponding 'value' and (optional) '_delete' key to specify
an XML attribute to delete.
"""
data_attrs = {}
attrs_map = {
'body': 'data-comment-body',
'title': 'data-comment-title',
'problem': 'data-problem-id'
}
for xml_key in attrs_map.keys():
if xml_key in el.attrib:
value = el.get(xml_key, '')
html_key = attrs_map[xml_key]
data_attrs[html_key] = { 'value': value, '_delete': xml_key }
return data_attrs
def _render_annotation(self, index, el):
""" Renders an annotation element for HTML output. """
attr = {}
attr.update(self._get_annotation_class_attr(index, el))
attr.update(self._get_annotation_data_attr(index, el))
el.tag = 'span'
for key in attr.keys():
el.set(key, attr[key]['value'])
if '_delete' in attr[key] and attr[key]['_delete'] is not None:
delete_key = attr[key]['_delete']
del el.attrib[delete_key]
def _render_content(self):
""" Renders annotatable content with annotation spans and returns HTML. """
xmltree = etree.fromstring(self.content)
xmltree.tag = 'div'
if 'display_name' in xmltree.attrib:
del xmltree.attrib['display_name']
index = 0
for el in xmltree.findall('.//annotation'):
self._render_annotation(index, el)
index += 1
return etree.tostring(xmltree, encoding='unicode')
def _extract_instructions(self, xmltree):
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
instructions = xmltree.find('instructions')
if instructions is not None:
instructions.tag = 'div'
xmltree.remove(instructions)
return etree.tostring(instructions, encoding='unicode')
return None
def get_html(self):
""" Renders parameters to template. """
context = {
'display_name': self.display_name,
'element_id': self.element_id,
'instructions_html': self.instructions,
'content_html': self._render_content()
}
return self.system.render_template('annotatable.html', context)
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)
xmltree = etree.fromstring(self.definition['data'])
self.instructions = self._extract_instructions(xmltree)
self.content = etree.tostring(xmltree, encoding='unicode')
self.element_id = self.location.html_id()
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
class AnnotatableDescriptor(RawDescriptor):
module_class = AnnotatableModule
stores_state = True
template_dir_name = "annotatable"
mako_template = "widgets/raw-edit.html"
...@@ -135,8 +135,8 @@ class CapaModule(XModule): ...@@ -135,8 +135,8 @@ class CapaModule(XModule):
self.grace_period = None self.grace_period = None
self.close_date = self.display_due_date self.close_date = self.display_due_date
max_attempts = self.metadata.get('attempts', None) max_attempts = self.metadata.get('attempts')
if max_attempts is not None: if max_attempts is not None and max_attempts != '':
self.max_attempts = int(max_attempts) self.max_attempts = int(max_attempts)
else: else:
self.max_attempts = None self.max_attempts = None
...@@ -582,7 +582,7 @@ class CapaModule(XModule): ...@@ -582,7 +582,7 @@ class CapaModule(XModule):
@staticmethod @staticmethod
def make_dict_of_responses(get): def make_dict_of_responses(get):
'''Make dictionary of student responses (aka "answers") '''Make dictionary of student responses (aka "answers")
get is POST dictionary. get is POST dictionary (Djano QueryDict).
The *get* dict has keys of the form 'x_y', which are mapped The *get* dict has keys of the form 'x_y', which are mapped
to key 'y' in the returned dict. For example, to key 'y' in the returned dict. For example,
...@@ -606,6 +606,7 @@ class CapaModule(XModule): ...@@ -606,6 +606,7 @@ class CapaModule(XModule):
to 'input_1' in the returned dict) to 'input_1' in the returned dict)
''' '''
answers = dict() answers = dict()
for key in get: for key in get:
# e.g. input_resistor_1 ==> resistor_1 # e.g. input_resistor_1 ==> resistor_1
_, _, name = key.partition('_') _, _, name = key.partition('_')
...@@ -613,7 +614,7 @@ class CapaModule(XModule): ...@@ -613,7 +614,7 @@ class CapaModule(XModule):
# If key has no underscores, then partition # If key has no underscores, then partition
# will return (key, '', '') # will return (key, '', '')
# We detect this and raise an error # We detect this and raise an error
if name is '': if not name:
raise ValueError("%s must contain at least one underscore" % str(key)) raise ValueError("%s must contain at least one underscore" % str(key))
else: else:
...@@ -625,10 +626,7 @@ class CapaModule(XModule): ...@@ -625,10 +626,7 @@ class CapaModule(XModule):
name = name[:-2] if is_list_key else name name = name[:-2] if is_list_key else name
if is_list_key: if is_list_key:
if type(get[key]) is list: val = get.getlist(key)
val = get[key]
else:
val = [get[key]]
else: else:
val = get[key] val = get[key]
......
...@@ -246,7 +246,7 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -246,7 +246,7 @@ class CourseDescriptor(SequenceDescriptor):
policy = json.loads(cls.read_grading_policy(paths, system)) policy = json.loads(cls.read_grading_policy(paths, system))
except ValueError: except ValueError:
system.error_tracker("Unable to decode grading policy as json") system.error_tracker("Unable to decode grading policy as json")
policy = None policy = {}
# cdodge: import the grading policy information that is on disk and put into the # cdodge: import the grading policy information that is on disk and put into the
# descriptor 'definition' bucket as a dictionary so that it is persisted in the DB # descriptor 'definition' bucket as a dictionary so that it is persisted in the DB
...@@ -398,7 +398,7 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -398,7 +398,7 @@ class CourseDescriptor(SequenceDescriptor):
return self.metadata.get("cohort_config", {}).get( return self.metadata.get("cohort_config", {}).get(
"auto_cohort_groups", []) "auto_cohort_groups", [])
@property @property
def top_level_discussion_topic_ids(self): def top_level_discussion_topic_ids(self):
""" """
......
$border-color: #C8C8C8;
$body-font-size: em(14);
.annotatable-header {
margin-bottom: .5em;
.annotatable-title {
font-size: em(22);
text-transform: uppercase;
padding: 2px 4px;
}
}
.annotatable-section {
position: relative;
padding: .5em 1em;
border: 1px solid $border-color;
border-radius: .5em;
margin-bottom: .5em;
&.shaded { background-color: #EDEDED; }
.annotatable-section-title {
font-weight: bold;
a { font-weight: normal; }
}
.annotatable-section-body {
border-top: 1px solid $border-color;
margin-top: .5em;
padding-top: .5em;
@include clearfix;
}
ul.instructions-template {
list-style: disc;
margin-left: 4em;
b { font-weight: bold; }
i { font-style: italic; }
code {
display: inline;
white-space: pre;
font-family: Courier New, monospace;
}
}
}
.annotatable-toggle {
position: absolute;
right: 0;
margin: 2px 1em 2px 0;
&.expanded:after { content: " \2191" }
&.collapsed:after { content: " \2193" }
}
.annotatable-span {
display: inline;
cursor: pointer;
@each $highlight in (
(yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)),
(red rgba(178,19,16,0.3) rgba(178,19,16,0.9)),
(orange rgba(255,165,0,0.3) rgba(255,165,0,0.9)),
(green rgba(25,255,132,0.3) rgba(25,255,132,0.9)),
(blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)),
(purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) {
$marker: nth($highlight,1);
$color: nth($highlight,2);
$selected_color: nth($highlight,3);
@if $marker == yellow {
&.highlight {
background-color: $color;
&.selected { background-color: $selected_color; }
}
}
&.highlight-#{$marker} {
background-color: $color;
&.selected { background-color: $selected_color; }
}
}
&.hide {
cursor: none;
background-color: inherit;
.annotatable-icon {
display: none;
}
}
.annotatable-comment {
display: none;
}
}
.ui-tooltip.qtip.ui-tooltip {
font-size: $body-font-size;
border: 1px solid #333;
border-radius: 1em;
background-color: rgba(0,0,0,.85);
color: #fff;
-webkit-font-smoothing: antialiased;
.ui-tooltip-titlebar {
font-size: em(16);
color: inherit;
background-color: transparent;
padding: 5px 10px;
border: none;
.ui-tooltip-title {
padding: 5px 0px;
border-bottom: 2px solid #333;
font-weight: bold;
}
.ui-tooltip-icon {
right: 10px;
background: #333;
}
.ui-state-hover {
color: inherit;
border: 1px solid #ccc;
}
}
.ui-tooltip-content {
color: inherit;
font-size: em(14);
text-align: left;
font-weight: 400;
padding: 0 10px 10px 10px;
background-color: transparent;
}
p {
color: inherit;
line-height: normal;
}
}
.ui-tooltip.qtip.ui-tooltip-annotatable {
max-width: 375px;
.ui-tooltip-content {
padding: 0 10px;
.annotatable-comment {
display: block;
margin: 0px 0px 10px 0;
max-height: 225px;
overflow: auto;
}
.annotatable-reply {
display: block;
border-top: 2px solid #333;
padding: 5px 0;
margin: 0;
text-align: center;
}
}
&:after {
content: '';
display: inline-block;
position: absolute;
bottom: -20px;
left: 50%;
height: 0;
width: 0;
margin-left: -5px;
border: 10px solid transparent;
border-top-color: rgba(0, 0, 0, .85);
}
}
...@@ -42,6 +42,14 @@ section.problem { ...@@ -42,6 +42,14 @@ section.problem {
label.choicegroup_correct{ label.choicegroup_correct{
&:after{ &:after{
content: url('../images/correct-icon.png'); content: url('../images/correct-icon.png');
margin-left:15px
}
}
label.choicegroup_incorrect{
&:after{
content: url('../images/incorrect-icon.png');
margin-left:15px;
} }
} }
...@@ -52,6 +60,7 @@ section.problem { ...@@ -52,6 +60,7 @@ section.problem {
.indicator_container { .indicator_container {
float: left; float: left;
width: 25px; width: 25px;
height: 1px;
margin-right: 15px; margin-right: 15px;
} }
...@@ -69,7 +78,7 @@ section.problem { ...@@ -69,7 +78,7 @@ section.problem {
} }
text { text {
display: block; display: inline;
margin-left: 25px; margin-left: 25px;
} }
} }
...@@ -231,6 +240,15 @@ section.problem { ...@@ -231,6 +240,15 @@ section.problem {
width: 25px; width: 25px;
} }
&.partially-correct {
@include inline-block();
background: url('../images/partially-correct-icon.png') center center no-repeat;
height: 20px;
position: relative;
top: 6px;
width: 25px;
}
&.incorrect, &.ui-icon-close { &.incorrect, &.ui-icon-close {
@include inline-block(); @include inline-block();
background: url('../images/incorrect-icon.png') center center no-repeat; background: url('../images/incorrect-icon.png') center center no-repeat;
...@@ -802,4 +820,91 @@ section.problem { ...@@ -802,4 +820,91 @@ section.problem {
display: none; display: none;
} }
} }
.annotation-input {
$yellow: rgba(255,255,10,0.3);
border: 1px solid #ccc;
border-radius: 1em;
margin: 0 0 1em 0;
.annotation-header {
font-weight: bold;
border-bottom: 1px solid #ccc;
padding: .5em 1em;
}
.annotation-body { padding: .5em 1em; }
a.annotation-return {
float: right;
font: inherit;
font-weight: normal;
}
a.annotation-return:after { content: " \2191" }
.block, ul.tags {
margin: .5em 0;
padding: 0;
}
.block-highlight {
padding: .5em;
color: #333;
font-style: normal;
background-color: $yellow;
border: 1px solid darken($yellow, 10%);
}
.block-comment { font-style: italic; }
ul.tags {
display: block;
list-style-type: none;
margin-left: 1em;
li {
display: block;
margin: 1em 0 0 0;
position: relative;
.tag {
display: inline-block;
cursor: pointer;
border: 1px solid rgb(102,102,102);
margin-left: 40px;
&.selected {
background-color: $yellow;
}
}
.tag-status {
position: absolute;
left: 0;
}
.tag-status, .tag { padding: .25em .5em; }
}
}
textarea.comment {
$num-lines-to-show: 5;
$line-height: 1.4em;
$padding: .2em;
width: 100%;
padding: $padding (2 * $padding);
line-height: $line-height;
height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2);
}
.answer-annotation { display: block; margin: 0; }
/* for debugging the input value field. enable the debug flag on the inputtype */
.debug-value {
color: #fff;
padding: 1em;
margin: 1em 0;
background-color: #999;
border: 1px solid #000;
input[type="text"] { width: 100%; }
pre { background-color: #CCC; color: #000; }
&:before {
display: block;
content: "debug input value";
text-transform: uppercase;
font-weight: bold;
font-size: 1.5em;
}
}
}
} }
...@@ -96,8 +96,9 @@ class FolditModule(XModule): ...@@ -96,8 +96,9 @@ class FolditModule(XModule):
self.required_level, self.required_level,
self.required_sublevel) self.required_sublevel)
showbasic = (self.metadata.get("show_basic_score").lower() == "true") showbasic = (self.metadata.get("show_basic_score", "").lower() == "true")
showleader = (self.metadata.get("show_leaderboard").lower() == "true") showleader = (self.metadata.get("show_leaderboard", "").lower() == "true")
context = { context = {
'due': self.due_str, 'due': self.due_str,
'success': self.is_complete(), 'success': self.is_complete(),
......
<section class='xmodule_display xmodule_AnnotatableModule' data-type='Annotatable'>
<div class="annotatable-wrapper">
<div class="annotatable-header">
<div class="annotatable-title">First Annotation Exercise</div>
</div>
<div class="annotatable-section">
<div class="annotatable-section-title">
Instructions
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">Collapse Instructions</a>
</div>
<div class="annotatable-section-body annotatable-instructions">
<div><p>The main goal of this exercise is to start practicing the art of slow reading.</p>
</div>
</div>
<div class="annotatable-section">
<div class="annotatable-section-title">
Guided Discussion
<a class="annotatable-toggle annotatable-toggle-annotations" href="javascript:void(0)">Hide Annotations</a>
</div>
</div>
<div class="annotatable-content">
|87 No, those who are really responsible are Zeus and Fate [Moira] and the Fury [Erinys] who roams in the mist. <br/>
|88 <span data-problem-id="0" data-comment-body="Agamemnon says..." class="annotatable-span highlight" data-comment-title="Your Title Here">They are the ones who</span><br/>
|100 He [= Zeus], making a formal declaration [eukhesthai], spoke up at a meeting of all the gods and said: <br/>
|101 <span data-problem-id="1" data-comment-body="When Zeus speaks..." class="annotatable-span highlight">“hear me, all gods and all goddesses,</span><br/>
|113 but he swore a great oath.
<span data-problem-id="2" data-comment-body="How is the ‘veering off-course’ ..." class="annotatable-span highlight">And right then and there</span><br/>
</div>
</div>
</section>
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
describe 'Annotatable', ->
beforeEach ->
loadFixtures 'annotatable.html'
describe 'constructor', ->
el = $('.xmodule_display.xmodule_AnnotatableModule')
beforeEach ->
@annotatable = new Annotatable(el)
it 'works', ->
expect(1).toBe(1)
\ No newline at end of file
class @Annotatable
_debug: false
# selectors for the annotatable xmodule
toggleAnnotationsSelector: '.annotatable-toggle-annotations'
toggleInstructionsSelector: '.annotatable-toggle-instructions'
instructionsSelector: '.annotatable-instructions'
sectionSelector: '.annotatable-section'
spanSelector: '.annotatable-span'
replySelector: '.annotatable-reply'
# these selectors are for responding to events from the annotation capa problem type
problemXModuleSelector: '.xmodule_CapaModule'
problemSelector: 'section.problem'
problemInputSelector: 'section.problem .annotation-input'
problemReturnSelector: 'section.problem .annotation-return'
constructor: (el) ->
console.log 'loaded Annotatable' if @_debug
@el = el
@$el = $(el)
@init()
$: (selector) ->
$(selector, @el)
init: () ->
@initEvents()
@initTips()
initEvents: () ->
# Initialize toggle handlers for the instructions and annotations sections
[@annotationsHidden, @instructionsHidden] = [false, false]
@$(@toggleAnnotationsSelector).bind 'click', @onClickToggleAnnotations
@$(@toggleInstructionsSelector).bind 'click', @onClickToggleInstructions
# Initialize handler for 'reply to annotation' events that scroll to
# the associated problem. The reply buttons are part of the tooltip
# content. It's important that the tooltips be configured to render
# as descendants of the annotation module and *not* the document.body.
@$el.delegate @replySelector, 'click', @onClickReply
# Initialize handler for 'return to annotation' events triggered from problems.
# 1) There are annotationinput capa problems rendered on the page
# 2) Each one has an embedded return link (see annotation capa problem template).
# Since the capa problem injects HTML content via AJAX, the best we can do is
# is let the click events bubble up to the body and handle them there.
$('body').delegate @problemReturnSelector, 'click', @onClickReturn
initTips: () ->
# tooltips are used to display annotations for highlighted text spans
@$(@spanSelector).each (index, el) =>
$(el).qtip(@getSpanTipOptions el)
getSpanTipOptions: (el) ->
content:
title:
text: @makeTipTitle(el)
text: @makeTipContent(el)
position:
my: 'bottom center' # of tooltip
at: 'top center' # of target
target: $(el) # where the tooltip was triggered (i.e. the annotation span)
container: @$el
adjust:
y: -5
show:
event: 'click mouseenter'
solo: true
hide:
event: 'click mouseleave'
delay: 500,
fixed: true # don't hide the tooltip if it is moused over
style:
classes: 'ui-tooltip-annotatable'
events:
show: @onShowTip
onClickToggleAnnotations: (e) => @toggleAnnotations()
onClickToggleInstructions: (e) => @toggleInstructions()
onClickReply: (e) => @replyTo(e.currentTarget)
onClickReturn: (e) => @returnFrom(e.currentTarget)
onShowTip: (event, api) =>
event.preventDefault() if @annotationsHidden
getSpanForProblemReturn: (el) ->
problem_id = $(@problemReturnSelector).index(el)
@$(@spanSelector).filter("[data-problem-id='#{problem_id}']")
getProblem: (el) ->
problem_id = @getProblemId(el)
$(@problemSelector).has(@problemInputSelector).eq(problem_id)
getProblemId: (el) ->
$(el).data('problem-id')
toggleAnnotations: () ->
hide = (@annotationsHidden = not @annotationsHidden)
@toggleAnnotationButtonText hide
@toggleSpans hide
@toggleTips hide
toggleTips: (hide) ->
visible = @findVisibleTips()
@hideTips visible
toggleAnnotationButtonText: (hide) ->
buttonText = (if hide then 'Show' else 'Hide')+' Annotations'
@$(@toggleAnnotationsSelector).text(buttonText)
toggleInstructions: () ->
hide = (@instructionsHidden = not @instructionsHidden)
@toggleInstructionsButton hide
@toggleInstructionsText hide
toggleInstructionsButton: (hide) ->
txt = (if hide then 'Expand' else 'Collapse')+' Instructions'
cls = (if hide then ['expanded', 'collapsed'] else ['collapsed','expanded'])
@$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1])
toggleInstructionsText: (hide) ->
slideMethod = (if hide then 'slideUp' else 'slideDown')
@$(@instructionsSelector)[slideMethod]()
toggleSpans: (hide) ->
@$(@spanSelector).toggleClass 'hide', hide, 250
replyTo: (buttonEl) ->
offset = -20
el = @getProblem buttonEl
if el.length > 0
@scrollTo(el, @afterScrollToProblem, offset)
else
console.log('problem not found. event: ', e) if @_debug
returnFrom: (buttonEl) ->
offset = -200
el = @getSpanForProblemReturn buttonEl
if el.length > 0
@scrollTo(el, @afterScrollToSpan, offset)
else
console.log('span not found. event:', e) if @_debug
scrollTo: (el, after, offset = -20) ->
$('html,body').scrollTo(el, {
duration: 500
onAfter: @_once => after?.call this, el
offset: offset
}) if $(el).length > 0
afterScrollToProblem: (problem_el) ->
problem_el.effect 'highlight', {}, 500
afterScrollToSpan: (span_el) ->
span_el.addClass 'selected', 400, 'swing', ->
span_el.removeClass 'selected', 400, 'swing'
makeTipContent: (el) ->
(api) =>
text = $(el).data('comment-body')
comment = @createComment(text)
problem_id = @getProblemId(el)
reply = @createReplyLink(problem_id)
$(comment).add(reply)
makeTipTitle: (el) ->
(api) =>
title = $(el).data('comment-title')
(if title then title else 'Commentary')
createComment: (text) ->
$("<div class=\"annotatable-comment\">#{text}</div>")
createReplyLink: (problem_id) ->
$("<a class=\"annotatable-reply\" href=\"javascript:void(0);\" data-problem-id=\"#{problem_id}\">Reply to Annotation</a>")
findVisibleTips: () ->
visible = []
@$(@spanSelector).each (index, el) ->
api = $(el).qtip('api')
tip = $(api?.elements.tooltip)
if tip.is(':visible')
visible.push el
visible
hideTips: (elements) ->
$(elements).qtip('hide')
_once: (fn) ->
done = false
return =>
fn.call this unless done
done = true
---
metadata:
display_name: 'Annotation'
data: |
<annotatable>
<instructions>
<p>Enter your (optional) instructions for the exercise in HTML format.</p>
<p>Annotations are specified by an <code>&lt;annotation&gt;</code> tag which may may have the following attributes:</p>
<ul class="instructions-template">
<li><code>title</code> (optional). Title of the annotation. Defaults to <i>Commentary</i> if omitted.</li>
<li><code>body</code> (<b>required</b>). Text of the annotation.</li>
<li><code>problem</code> (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have <code>problem="0"</code>.</li>
<li><code>highlight</code> (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.</li>
</ul>
</instructions>
<p>Add your HTML with annotation spans here.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <annotation title="My title" body="My comment" highlight="yellow" problem="0">Ut sodales laoreet est, egestas gravida felis egestas nec.</annotation> Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.</p>
<p>Nulla facilisi. <annotation body="Basic annotation example." problem="1">Pellentesque id vestibulum libero.</annotation> Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.</p>
</annotatable>
children: []
"""Module annotatable tests"""
import unittest
from lxml import etree
from mock import Mock
from xmodule.annotatable_module import AnnotatableModule
from xmodule.modulestore import Location
from . import test_system
class AnnotatableModuleTestCase(unittest.TestCase):
location = Location(["i4x", "edX", "toy", "annotatable", "guided_discussion"])
sample_xml = '''
<annotatable display_name="Iliad">
<instructions>Read the text.</instructions>
<p>
<annotation body="first">Sing</annotation>,
<annotation title="goddess" body="second">O goddess</annotation>,
<annotation title="anger" body="third" highlight="blue">the anger of Achilles son of Peleus</annotation>,
that brought <i>countless</i> ills upon the Achaeans. Many a brave soul did it send
hurrying down to Hades, and many a hero did it yield a prey to dogs and
<div style="font-weight:bold"><annotation body="fourth" problem="4">vultures</annotation>, for so were the counsels
of Jove fulfilled from the day on which the son of Atreus, king of men, and great
Achilles, first fell out with one another.</div>
</p>
<annotation title="footnote" body="the end">The Iliad of Homer by Samuel Butler</annotation>
</annotatable>
'''
definition = { 'data': sample_xml }
descriptor = Mock()
instance_state = None
shared_state = None
def setUp(self):
self.annotatable = AnnotatableModule(test_system(), self.location, self.definition, self.descriptor, self.instance_state, self.shared_state)
def test_annotation_data_attr(self):
el = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')
expected_attr = {
'data-comment-body': {'value': 'foo', '_delete': 'body' },
'data-comment-title': {'value': 'bar', '_delete': 'title'},
'data-problem-id': {'value': '0', '_delete': 'problem'}
}
actual_attr = self.annotatable._get_annotation_data_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_default(self):
xml = '<annotation title="x" body="y" problem="0">test</annotation>'
el = etree.fromstring(xml)
expected_attr = { 'class': { 'value': 'annotatable-span highlight' } }
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_with_valid_highlight(self):
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
for color in self.annotatable.highlight_colors:
el = etree.fromstring(xml.format(highlight=color))
value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color)
expected_attr = { 'class': {
'value': value,
'_delete': 'highlight' }
}
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_with_invalid_highlight(self):
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
for invalid_color in ['rainbow', 'blink', 'invisible', '', None]:
el = etree.fromstring(xml.format(highlight=invalid_color))
expected_attr = { 'class': {
'value': 'annotatable-span highlight',
'_delete': 'highlight' }
}
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_render_annotation(self):
expected_html = '<span class="annotatable-span highlight highlight-yellow" data-comment-title="x" data-comment-body="y" data-problem-id="0">z</span>'
expected_el = etree.fromstring(expected_html)
actual_el = etree.fromstring('<annotation title="x" body="y" problem="0" highlight="yellow">z</annotation>')
self.annotatable._render_annotation(0, actual_el)
self.assertEqual(expected_el.tag, actual_el.tag)
self.assertEqual(expected_el.text, actual_el.text)
self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib))
def test_render_content(self):
content = self.annotatable._render_content()
el = etree.fromstring(content)
self.assertEqual('div', el.tag, 'root tag is a div')
expected_num_annotations = 5
actual_num_annotations = el.xpath('count(//span[contains(@class,"annotatable-span")])')
self.assertEqual(expected_num_annotations, actual_num_annotations, 'check number of annotations')
def test_get_html(self):
context = self.annotatable.get_html()
for key in ['display_name', 'element_id', 'content_html', 'instructions_html']:
self.assertIn(key, context)
def test_extract_instructions(self):
xmltree = etree.fromstring(self.sample_xml)
expected_xml = u"<div>Read the text.</div>"
actual_xml = self.annotatable._extract_instructions(xmltree)
self.assertIsNotNone(actual_xml)
self.assertEqual(expected_xml.strip(), actual_xml.strip())
xmltree = etree.fromstring('<annotatable>foo</annotatable>')
actual = self.annotatable._extract_instructions(xmltree)
self.assertIsNone(actual)
...@@ -11,6 +11,8 @@ from xmodule.capa_module import CapaModule ...@@ -11,6 +11,8 @@ from xmodule.capa_module import CapaModule
from xmodule.modulestore import Location from xmodule.modulestore import Location
from lxml import etree from lxml import etree
from django.http import QueryDict
from . import test_system from . import test_system
...@@ -44,7 +46,7 @@ class CapaFactory(object): ...@@ -44,7 +46,7 @@ class CapaFactory(object):
@staticmethod @staticmethod
def answer_key(): def answer_key():
""" Return the key stored in the capa problem answer dict """ """ Return the key stored in the capa problem answer dict """
return ("-".join(['i4x', 'edX', 'capa_test', 'problem', return ("-".join(['i4x', 'edX', 'capa_test', 'problem',
'SampleProblem%d' % CapaFactory.num]) + 'SampleProblem%d' % CapaFactory.num]) +
"_2_1") "_2_1")
...@@ -144,6 +146,8 @@ class CapaModuleTest(unittest.TestCase): ...@@ -144,6 +146,8 @@ class CapaModuleTest(unittest.TestCase):
"Factory should be creating unique names for each problem") "Factory should be creating unique names for each problem")
def test_correct(self): def test_correct(self):
""" """
Check that the factory creates correct and incorrect problems properly. Check that the factory creates correct and incorrect problems properly.
...@@ -324,15 +328,19 @@ class CapaModuleTest(unittest.TestCase): ...@@ -324,15 +328,19 @@ class CapaModuleTest(unittest.TestCase):
def test_parse_get_params(self): def test_parse_get_params(self):
# We have to set up Django settings in order to use QueryDict
from django.conf import settings
settings.configure()
# Valid GET param dict # Valid GET param dict
valid_get_dict = {'input_1': 'test', valid_get_dict = self._querydict_from_dict({'input_1': 'test',
'input_1_2': 'test', 'input_1_2': 'test',
'input_1_2_3': 'test', 'input_1_2_3': 'test',
'input_[]_3': 'test', 'input_[]_3': 'test',
'input_4': None, 'input_4': None,
'input_5': [], 'input_5': [],
'input_6': 5} 'input_6': 5})
result = CapaModule.make_dict_of_responses(valid_get_dict) result = CapaModule.make_dict_of_responses(valid_get_dict)
# Expect that we get a dict with "input" stripped from key names # Expect that we get a dict with "input" stripped from key names
...@@ -345,20 +353,19 @@ class CapaModuleTest(unittest.TestCase): ...@@ -345,20 +353,19 @@ class CapaModuleTest(unittest.TestCase):
# Valid GET param dict with list keys # Valid GET param dict with list keys
valid_get_dict = {'input_2[]': ['test1', 'test2']} valid_get_dict = self._querydict_from_dict({'input_2[]': ['test1', 'test2']})
result = CapaModule.make_dict_of_responses(valid_get_dict) result = CapaModule.make_dict_of_responses(valid_get_dict)
self.assertTrue('2' in result) self.assertTrue('2' in result)
self.assertEqual(valid_get_dict['input_2[]'], result['2']) self.assertEqual(['test1','test2'], result['2'])
# If we use [] at the end of a key name, we should always # If we use [] at the end of a key name, we should always
# get a list, even if there's just one value # get a list, even if there's just one value
valid_get_dict = {'input_1[]': 'test'} valid_get_dict = self._querydict_from_dict({'input_1[]': 'test'})
result = CapaModule.make_dict_of_responses(valid_get_dict) result = CapaModule.make_dict_of_responses(valid_get_dict)
self.assertEqual(result['1'], ['test']) self.assertEqual(result['1'], ['test'])
# If we have no underscores in the name, then the key is invalid # If we have no underscores in the name, then the key is invalid
invalid_get_dict = {'input': 'test'} invalid_get_dict = self._querydict_from_dict({'input': 'test'})
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
result = CapaModule.make_dict_of_responses(invalid_get_dict) result = CapaModule.make_dict_of_responses(invalid_get_dict)
...@@ -366,11 +373,32 @@ class CapaModuleTest(unittest.TestCase): ...@@ -366,11 +373,32 @@ class CapaModuleTest(unittest.TestCase):
# Two equivalent names (one list, one non-list) # Two equivalent names (one list, one non-list)
# One of the values would overwrite the other, so detect this # One of the values would overwrite the other, so detect this
# and raise an exception # and raise an exception
invalid_get_dict = {'input_1[]': 'test 1', invalid_get_dict = self._querydict_from_dict({'input_1[]': 'test 1',
'input_1': 'test 2' } 'input_1': 'test 2' })
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
result = CapaModule.make_dict_of_responses(invalid_get_dict) result = CapaModule.make_dict_of_responses(invalid_get_dict)
def _querydict_from_dict(self, param_dict):
""" Create a Django QueryDict from a Python dictionary """
# QueryDict objects are immutable by default, so we make
# a copy that we can update.
querydict = QueryDict('')
copyDict = querydict.copy()
for (key, val) in param_dict.items():
# QueryDicts handle lists differently from ordinary values,
# so we have to specifically tell the QueryDict that
# this is a list
if type(val) is list:
copyDict.setlist(key, val)
else:
copyDict[key] = val
return copyDict
def test_check_problem_correct(self): def test_check_problem_correct(self):
module = CapaFactory.create(attempts=1) module = CapaFactory.create(attempts=1)
...@@ -475,7 +503,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -475,7 +503,7 @@ class CapaModuleTest(unittest.TestCase):
mock_is_queued.return_value = True mock_is_queued.return_value = True
mock_get_queuetime.return_value = datetime.datetime.now() mock_get_queuetime.return_value = datetime.datetime.now()
get_request_dict = { CapaFactory.input_key(): '3.14' } get_request_dict = { CapaFactory.input_key(): '3.14' }
result = module.check_problem(get_request_dict) result = module.check_problem(get_request_dict)
...@@ -506,7 +534,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -506,7 +534,7 @@ class CapaModuleTest(unittest.TestCase):
def test_reset_problem(self): def test_reset_problem(self):
module = CapaFactory.create() module = CapaFactory.create()
# Mock the module's capa problem # Mock the module's capa problem
# to simulate that the problem is done # to simulate that the problem is done
mock_problem = MagicMock(capa.capa_problem.LoncapaProblem) mock_problem = MagicMock(capa.capa_problem.LoncapaProblem)
mock_problem.done = True mock_problem.done = True
...@@ -668,7 +696,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -668,7 +696,7 @@ class CapaModuleTest(unittest.TestCase):
module = CapaFactory.create(max_attempts=0) module = CapaFactory.create(max_attempts=0)
self.assertFalse(module.should_show_check_button()) self.assertFalse(module.should_show_check_button())
# If user submitted a problem but hasn't reset, # If user submitted a problem but hasn't reset,
# do NOT show the check button # do NOT show the check button
# Note: we can only reset when rerandomize="always" # Note: we can only reset when rerandomize="always"
module = CapaFactory.create(rerandomize="always") module = CapaFactory.create(rerandomize="always")
...@@ -707,7 +735,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -707,7 +735,7 @@ class CapaModuleTest(unittest.TestCase):
module.lcp.done = True module.lcp.done = True
self.assertFalse(module.should_show_reset_button()) self.assertFalse(module.should_show_reset_button())
# If the user hasn't submitted an answer yet, # If the user hasn't submitted an answer yet,
# then do NOT show the reset button # then do NOT show the reset button
module = CapaFactory.create() module = CapaFactory.create()
module.lcp.done = False module.lcp.done = False
...@@ -770,7 +798,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -770,7 +798,7 @@ class CapaModuleTest(unittest.TestCase):
# If the user is out of attempts, do NOT show the save button # If the user is out of attempts, do NOT show the save button
attempts = random.randint(1,10) attempts = random.randint(1,10)
module = CapaFactory.create(attempts=attempts, module = CapaFactory.create(attempts=attempts,
max_attempts=attempts, max_attempts=attempts,
force_save_button="true") force_save_button="true")
module.lcp.done = True module.lcp.done = True
...@@ -784,6 +812,12 @@ class CapaModuleTest(unittest.TestCase): ...@@ -784,6 +812,12 @@ class CapaModuleTest(unittest.TestCase):
module.lcp.done = True module.lcp.done = True
self.assertTrue(module.should_show_save_button()) self.assertTrue(module.should_show_save_button())
def test_no_max_attempts(self):
module = CapaFactory.create(max_attempts='')
html = module.get_problem_html()
# assert that we got here without exploding
def test_get_problem_html(self): def test_get_problem_html(self):
module = CapaFactory.create() module = CapaFactory.create()
...@@ -797,7 +831,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -797,7 +831,7 @@ class CapaModuleTest(unittest.TestCase):
module.should_show_reset_button = Mock(return_value=show_reset_button) module.should_show_reset_button = Mock(return_value=show_reset_button)
module.should_show_save_button = Mock(return_value=show_save_button) module.should_show_save_button = Mock(return_value=show_save_button)
# Mock the system rendering function # Mock the system rendering function
module.system.render_template = Mock(return_value="<div>Test Template HTML</div>") module.system.render_template = Mock(return_value="<div>Test Template HTML</div>")
# Patch the capa problem's HTML rendering # Patch the capa problem's HTML rendering
...@@ -809,7 +843,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -809,7 +843,7 @@ class CapaModuleTest(unittest.TestCase):
# Also render the problem encapsulated in a <div> # Also render the problem encapsulated in a <div>
html_encapsulated = module.get_problem_html(encapsulate=True) html_encapsulated = module.get_problem_html(encapsulate=True)
# Expect that we get the rendered template back # Expect that we get the rendered template back
self.assertEqual(html, "<div>Test Template HTML</div>") self.assertEqual(html, "<div>Test Template HTML</div>")
...@@ -831,7 +865,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -831,7 +865,7 @@ class CapaModuleTest(unittest.TestCase):
def test_get_problem_html_error(self): def test_get_problem_html_error(self):
""" """
In production, when an error occurs with the problem HTML In production, when an error occurs with the problem HTML
rendering, a "dummy" problem is created with an error rendering, a "dummy" problem is created with an error
message to display to the user. message to display to the user.
...@@ -845,10 +879,10 @@ class CapaModuleTest(unittest.TestCase): ...@@ -845,10 +879,10 @@ class CapaModuleTest(unittest.TestCase):
# is asked to render itself as HTML # is asked to render itself as HTML
module.lcp.get_html = Mock(side_effect=Exception("Test")) module.lcp.get_html = Mock(side_effect=Exception("Test"))
# Stub out the test_system rendering function # Stub out the test_system rendering function
module.system.render_template = Mock(return_value="<div>Test Template HTML</div>") module.system.render_template = Mock(return_value="<div>Test Template HTML</div>")
# Turn off DEBUG # Turn off DEBUG
module.system.DEBUG = False module.system.DEBUG = False
# Try to render the module with DEBUG turned off # Try to render the module with DEBUG turned off
...@@ -860,4 +894,4 @@ class CapaModuleTest(unittest.TestCase): ...@@ -860,4 +894,4 @@ class CapaModuleTest(unittest.TestCase):
self.assertTrue("error" in context['problem']['html']) self.assertTrue("error" in context['problem']['html'])
# Expect that the module has created a new dummy problem with the error # Expect that the module has created a new dummy problem with the error
self.assertNotEqual(original_problem, module.lcp) self.assertNotEqual(original_problem, module.lcp)
...@@ -379,7 +379,11 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -379,7 +379,11 @@ class XmlDescriptor(XModuleDescriptor):
if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy: if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy:
val = val_for_xml(attr) val = val_for_xml(attr)
#logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr)) #logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr))
xml_object.set(attr, val) try:
xml_object.set(attr, val)
except Exception, e:
logging.exception('Failed to serialize metadata attribute {0} with value {1}. This could mean data loss!!! Exception: {2}'.format(attr, val, e))
pass
if self.export_to_file(): if self.export_to_file():
# Write the definition to a file # Write the definition to a file
......
...@@ -88,7 +88,7 @@ if Backbone? ...@@ -88,7 +88,7 @@ if Backbone?
if @$('section.discussion').length if @$('section.discussion').length
@$('section.discussion').replaceWith($discussion) @$('section.discussion').replaceWith($discussion)
else else
$(".discussion-module").append($discussion) @$el.append($discussion)
@newPostForm = $('.new-post-article') @newPostForm = $('.new-post-article')
@threadviews = @discussion.map (thread) -> @threadviews = @discussion.map (thread) ->
new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread
......
(function () {
var debug = false;
var module = {
debug: debug,
inputSelector: '.annotation-input',
tagSelector: '.tag',
tagsSelector: '.tags',
commentSelector: 'textarea.comment',
valueSelector: 'input.value', // stash tag selections and comment here as a JSON string...
singleSelect: true,
init: function() {
var that = this;
if(this.debug) { console.log('annotation input loaded: '); }
$(this.inputSelector).each(function(index, el) {
if(!$(el).data('listening')) {
$(el).delegate(that.tagSelector, 'click', $.proxy(that.onClickTag, that));
$(el).delegate(that.commentSelector, 'change', $.proxy(that.onChangeComment, that));
$(el).data('listening', 'yes');
}
});
},
onChangeComment: function(e) {
var value_el = this.findValueEl(e.target);
var current_value = this.loadValue(value_el);
var target_value = $(e.target).val();
current_value.comment = target_value;
this.storeValue(value_el, current_value);
},
onClickTag: function(e) {
var target_el = e.target, target_value, target_index;
var value_el, current_value;
value_el = this.findValueEl(e.target);
current_value = this.loadValue(value_el);
target_value = $(e.target).data('id');
if(!$(target_el).hasClass('selected')) {
if(this.singleSelect) {
current_value.options = [target_value]
} else {
current_value.options.push(target_value);
}
} else {
if(this.singleSelect) {
current_value.options = []
} else {
target_index = current_value.options.indexOf(target_value);
if(target_index !== -1) {
current_value.options.splice(target_index, 1);
}
}
}
this.storeValue(value_el, current_value);
if(this.singleSelect) {
$(target_el).closest(this.tagsSelector)
.find(this.tagSelector)
.not(target_el)
.removeClass('selected')
}
$(target_el).toggleClass('selected');
},
findValueEl: function(target_el) {
var input_el = $(target_el).closest(this.inputSelector);
return $(this.valueSelector, input_el);
},
loadValue: function(value_el) {
var json = $(value_el).val();
var result = JSON.parse(json);
if(result === null) {
result = {};
}
if(!result.hasOwnProperty('options')) {
result.options = [];
}
if(!result.hasOwnProperty('comment')) {
result.comment = '';
}
return result;
},
storeValue: function(value_el, new_value) {
var json = JSON.stringify(new_value);
$(value_el).val(json);
}
}
module.init();
}).call(this);
...@@ -11,9 +11,14 @@ ...@@ -11,9 +11,14 @@
} }
prev_id = "#" + this.id + "_preview"; prev_id = "#" + this.id + "_preview";
preview_div = $(prev_id) preview_div = $(prev_id);
$.get("/preview/chemcalc/", {"formula" : this.value}, create_handler(preview_div)); // find the closest parent problems-wrapper and use that url
url = $(this).closest('.problems-wrapper').data('url');
// grab the input id from the input
input_id = $(this).data('input-id')
Problem.inputAjax(url, input_id, 'preview_chemcalc', {"formula" : this.value}, create_handler(preview_div));
} }
inputs = $('.chemicalequationinput input'); inputs = $('.chemicalequationinput input');
......
(function () { (function () {
var timeout = 1000; var timeout = 1000;
function initializeApplet(applet) { waitForGenex();
console.log("Initializing " + applet);
waitForApplet(applet);
}
function waitForApplet(applet) { function waitForGenex() {
if (applet.isActive && applet.isActive()) { if (typeof(genex) !== "undefined" && genex) {
console.log("Applet is ready."); genex.onInjectionDone("genex");
var answerStr = applet.checkAnswer(); }
console.log(answerStr); else {
var input = $('.editageneinput input'); setTimeout(function() { waitForGenex(); }, timeout);
console.log(input);
input.val(answerStr);
} else if (timeout > 30 * 1000) {
console.error("Applet did not load on time.");
} else {
console.log("Waiting for applet...");
setTimeout(function() { waitForApplet(applet); }, timeout);
} }
} }
var applets = $('.editageneinput object'); //NOTE:
applets.each(function(i, el) { initializeApplet(el); }); // Genex uses six global functions:
// genexSetDNASequence (exported from GWT)
// genexSetClickEvent (exported from GWT)
// genexSetKeyEvent (exported from GWT)
// genexSetProblemNumber (exported from GWT)
//
// It calls genexIsReady with a deferred command when it has finished
// initialization and has drawn itself
// genexStoreAnswer(answer) is called when the GWT [Store Answer] button
// is clicked
genexIsReady = function() {
//Load DNA sequence
var dna_sequence = $('#dna_sequence').val();
genexSetDNASequence(dna_sequence);
//Now load mouse and keyboard handlers
genexSetClickEvent();
genexSetKeyEvent();
//Now load problem
var genex_problem_number = $('#genex_problem_number').val();
genexSetProblemNumber(genex_problem_number);
};
genexStoreAnswer = function(ans) {
var problem = $('#genex_container').parents('.problem');
var input_field = problem.find('input[type="hidden"][name!="dna_sequence"][name!="genex_problem_number"]');
input_field.val(ans);
};
}).call(this); }).call(this);
.genex-button {
margin-right: -8px;
height: 40px !important;
}
.genex-label {
/*font: normal normal normal 10pt/normal 'Open Sans', Verdana, Geneva, sans-serif !important;*/
/*padding: 4px 0px 0px 10px !important;*/
font-family: sans-serif !important;
font-size: 13px !important;
font-style: normal !important;
font-variant: normal !important;
font-weight: bold !important;
padding-top: 6px !important;
margin-left: 18px;
}
.gwt-HTML {
cursor: default;
overflow-x: auto !important;
overflow-y: auto !important;
background-color: rgb(248, 248, 248) !important;
}
.genex-scrollpanel {
word-wrap: normal !important;
white-space: pre !important;
}
pre, #dna-strand {
font-family: 'courier new', courier !important;
font-size: 13px !important;
font-style: normal !important;
font-variant: normal !important;
font-weight: normal !important;
border-style: none !important;
background-color: rgb(248, 248, 248) !important;
word-wrap: normal !important;
white-space: pre !important;
overflow-x: visible !important;
overflow-y: visible !important;
}
.gwt-DialogBox .Caption {
background: #F1F1F1;
padding: 4px 8px 4px 4px;
cursor: default;
font-family: Arial Unicode MS, Arial, sans-serif;
font-weight: bold;
border-bottom: 1px solid #bbbbbb;
border-top: 1px solid #D2D2D2;
}
.gwt-DialogBox .dialogContent {
}
.gwt-DialogBox .dialogMiddleCenter {
padding: 3px;
background: white;
}
.gwt-DialogBox .dialogBottomCenter {
}
.gwt-DialogBox .dialogMiddleLeft {
}
.gwt-DialogBox .dialogMiddleRight {
}
.gwt-DialogBox .dialogTopLeftInner {
width: 10px;
height: 8px;
zoom: 1;
}
.gwt-DialogBox .dialogTopRightInner {
width: 12px;
zoom: 1;
}
.gwt-DialogBox .dialogBottomLeftInner {
width: 10px;
height: 12px;
zoom: 1;
}
.gwt-DialogBox .dialogBottomRightInner {
width: 12px;
height: 12px;
zoom: 1;
}
.gwt-DialogBox .dialogTopLeft {
}
.gwt-DialogBox .dialogTopRight {
}
.gwt-DialogBox .dialogBottomLeft {
}
.gwt-DialogBox .dialogBottomRight {
}
* html .gwt-DialogBox .dialogTopLeftInner {
width: 10px;
overflow: hidden;
}
* html .gwt-DialogBox .dialogTopRightInner {
width: 12px;
overflow: hidden;
}
* html .gwt-DialogBox .dialogBottomLeftInner {
width: 10px;
height: 12px;
overflow: hidden;
}
* html .gwt-DialogBox .dialogBottomRightInner {
width: 12px;
height: 12px;
overflow: hidden;
}
\ No newline at end of file
function genex(){var P='',xb='" for "gwt:onLoadErrorFn"',vb='" for "gwt:onPropertyErrorFn"',ib='"><\/script>',Z='#',Xb='.cache.html',_='/',lb='//',Qb='026A6180B5959B8660E084245FEE5E9E',Rb='1F433010E1134C95BF6CB43F552F3019',Sb='2DDA730EDABB80B88A6B0DFA3AFEACA2',Tb='4EEB1DCF4B30D366C27968D1B5C0BD04',Ub='5033ABB047340FB9346B622E2CC7107D',Wb=':',pb='::',dc='<script defer="defer">genex.onInjectionDone(\'genex\')<\/script>',hb='<script id="',sb='=',$='?',ub='Bad handler "',Vb='DF3D3A7FAEE63D711CF2D95BDB3F538C',cc='DOMContentLoaded',jb='SCRIPT',gb='__gwt_marker_genex',kb='base',cb='baseUrl',T='begin',S='bootstrap',bb='clear.cache.gif',rb='content',Y='end',Kb='gecko',Lb='gecko1_8',Q='genex',Yb='genex.css',eb='genex.nocache.js',ob='genex::',U='gwt.codesvr=',V='gwt.hosted=',W='gwt.hybrid',wb='gwt:onLoadErrorFn',tb='gwt:onPropertyErrorFn',qb='gwt:property',bc='head',Ob='hosted.html?genex',ac='href',Jb='ie6',Ib='ie8',Hb='ie9',yb='iframe',ab='img',zb="javascript:''",Zb='link',Nb='loadExternalRefs',mb='meta',Bb='moduleRequested',X='moduleStartup',Gb='msie',nb='name',Db='opera',Ab='position:absolute;width:0;height:0;border:none',$b='rel',Fb='safari',db='script',Pb='selectingPermutation',R='startup',_b='stylesheet',fb='undefined',Mb='unknown',Cb='user.agent',Eb='webkit';var m=window,n=document,o=m.__gwtStatsEvent?function(a){return m.__gwtStatsEvent(a)}:null,p=m.__gwtStatsSessionId?m.__gwtStatsSessionId:null,q,r,s,t=P,u={},v=[],w=[],x=[],y=0,z,A;o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:S,millis:(new Date).getTime(),type:T});if(!m.__gwt_stylesLoaded){m.__gwt_stylesLoaded={}}if(!m.__gwt_scriptsLoaded){m.__gwt_scriptsLoaded={}}function B(){var b=false;try{var c=m.location.search;return (c.indexOf(U)!=-1||(c.indexOf(V)!=-1||m.external&&m.external.gwtOnLoad))&&c.indexOf(W)==-1}catch(a){}B=function(){return b};return b}
function C(){if(q&&r){var b=n.getElementById(Q);var c=b.contentWindow;if(B()){c.__gwt_getProperty=function(a){return H(a)}}genex=null;c.gwtOnLoad(z,Q,t,y);o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:X,millis:(new Date).getTime(),type:Y})}}
function D(){function e(a){var b=a.lastIndexOf(Z);if(b==-1){b=a.length}var c=a.indexOf($);if(c==-1){c=a.length}var d=a.lastIndexOf(_,Math.min(c,b));return d>=0?a.substring(0,d+1):P}
function f(a){if(a.match(/^\w+:\/\//)){}else{var b=n.createElement(ab);b.src=a+bb;a=e(b.src)}return a}
function g(){var a=F(cb);if(a!=null){return a}return P}
function h(){var a=n.getElementsByTagName(db);for(var b=0;b<a.length;++b){if(a[b].src.indexOf(eb)!=-1){return e(a[b].src)}}return P}
function i(){var a;if(typeof isBodyLoaded==fb||!isBodyLoaded()){var b=gb;var c;n.write(hb+b+ib);c=n.getElementById(b);a=c&&c.previousSibling;while(a&&a.tagName!=jb){a=a.previousSibling}if(c){c.parentNode.removeChild(c)}if(a&&a.src){return e(a.src)}}return P}
function j(){var a=n.getElementsByTagName(kb);if(a.length>0){return a[a.length-1].href}return P}
function k(){var a=n.location;return a.href==a.protocol+lb+a.host+a.pathname+a.search+a.hash}
var l=g();if(l==P){l=h()}if(l==P){l=i()}if(l==P){l=j()}if(l==P&&k()){l=e(n.location.href)}l=f(l);t=l;return l}
function E(){var b=document.getElementsByTagName(mb);for(var c=0,d=b.length;c<d;++c){var e=b[c],f=e.getAttribute(nb),g;if(f){f=f.replace(ob,P);if(f.indexOf(pb)>=0){continue}if(f==qb){g=e.getAttribute(rb);if(g){var h,i=g.indexOf(sb);if(i>=0){f=g.substring(0,i);h=g.substring(i+1)}else{f=g;h=P}u[f]=h}}else if(f==tb){g=e.getAttribute(rb);if(g){try{A=eval(g)}catch(a){alert(ub+g+vb)}}}else if(f==wb){g=e.getAttribute(rb);if(g){try{z=eval(g)}catch(a){alert(ub+g+xb)}}}}}}
function F(a){var b=u[a];return b==null?null:b}
function G(a,b){var c=x;for(var d=0,e=a.length-1;d<e;++d){c=c[a[d]]||(c[a[d]]=[])}c[a[e]]=b}
function H(a){var b=w[a](),c=v[a];if(b in c){return b}var d=[];for(var e in c){d[c[e]]=e}if(A){A(a,d,b)}throw null}
var I;function J(){if(!I){I=true;var a=n.createElement(yb);a.src=zb;a.id=Q;a.style.cssText=Ab;a.tabIndex=-1;n.body.appendChild(a);o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:X,millis:(new Date).getTime(),type:Bb});a.contentWindow.location.replace(t+L)}}
w[Cb]=function(){var b=navigator.userAgent.toLowerCase();var c=function(a){return parseInt(a[1])*1000+parseInt(a[2])};if(function(){return b.indexOf(Db)!=-1}())return Db;if(function(){return b.indexOf(Eb)!=-1}())return Fb;if(function(){return b.indexOf(Gb)!=-1&&n.documentMode>=9}())return Hb;if(function(){return b.indexOf(Gb)!=-1&&n.documentMode>=8}())return Ib;if(function(){var a=/msie ([0-9]+)\.([0-9]+)/.exec(b);if(a&&a.length==3)return c(a)>=6000}())return Jb;if(function(){return b.indexOf(Kb)!=-1}())return Lb;return Mb};v[Cb]={gecko1_8:0,ie6:1,ie8:2,ie9:3,opera:4,safari:5};genex.onScriptLoad=function(){if(I){r=true;C()}};genex.onInjectionDone=function(){q=true;o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:Nb,millis:(new Date).getTime(),type:Y});C()};E();D();var K;var L;if(B()){if(m.external&&(m.external.initModule&&m.external.initModule(Q))){m.location.reload();return}L=Ob;K=P}o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:S,millis:(new Date).getTime(),type:Pb});if(!B()){try{G([Fb],Qb);G([Lb],Rb);G([Hb],Sb);G([Jb],Tb);G([Db],Ub);G([Ib],Vb);K=x[H(Cb)];var M=K.indexOf(Wb);if(M!=-1){y=Number(K.substring(M+1));K=K.substring(0,M)}L=K+Xb}catch(a){return}}var N;function O(){if(!s){s=true;if(!__gwt_stylesLoaded[Yb]){var a=n.createElement(Zb);__gwt_stylesLoaded[Yb]=a;a.setAttribute($b,_b);a.setAttribute(ac,t+Yb);n.getElementsByTagName(bc)[0].appendChild(a)}C();if(n.removeEventListener){n.removeEventListener(cc,O,false)}if(N){clearInterval(N)}}}
if(n.addEventListener){n.addEventListener(cc,function(){J();O()},false)}var N=setInterval(function(){if(/loaded|complete/.test(n.readyState)){J();O()}},50);o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:S,millis:(new Date).getTime(),type:Y});o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:Nb,millis:(new Date).getTime(),type:T});n.write(dc)}
genex();
\ No newline at end of file
...@@ -550,15 +550,84 @@ If you want to customize the courseware tabs displayed for your course, specify ...@@ -550,15 +550,84 @@ If you want to customize the courseware tabs displayed for your course, specify
********* *********
Textbooks Textbooks
********* *********
Support is currently provided for image-based and PDF-based textbooks. Support is currently provided for image-based and PDF-based textbooks. In addition to enabling the display of textbooks in tabs (see above), specific information about the location of textbook content must be configured.
Image-based Textbooks Image-based Textbooks
^^^^^^^^^^^^^^^^^^^^^ =====================
Configuration
-------------
Image-based textbooks are configured at the course level in the XML markup. Here is an example:
.. code-block:: xml
<course>
<textbook title="Textbook 1" book_url="https://www.example.com/textbook_1/" />
<textbook title="Textbook 2" book_url="https://www.example.com/textbook_2/" />
<chapter url_name="Overview">
<chapter url_name="First week">
</course>
Each `textbook` element is displayed on a different tab. The `title` attribute is used as the tab's name, and the `book_url` attribute points to the remote directory that contains the images of the text. Note the trailing slash on the end of the `book_url` attribute.
The images must be stored in the same directory as the `book_url`, with filenames matching `pXXX.png`, where `XXX` is a three-digit number representing the page number (with leading zeroes as necessary). Pages start at `p001.png`.
Each textbook must also have its own table of contents. This is read from the `book_url` location, by appending `toc.xml`. This file contains a `table_of_contents` parent element, with `entry` elements nested below it. Each `entry` has attributes for `name`, `page_label`, and `page`, as well as an optional `chapter` attribute. An arbitrary number of levels of nesting of `entry` elements within other `entry` elements is supported, but you're likely to only want two levels. The `page` represents the actual page to link to, while the `page_label` matches the displayed page number on that page. Here's an example:
.. code-block:: xml
<table_of_contents>
<entry page="1" page_label="i" name="Title" />
<entry page="2" page_label="ii" name="Preamble">
<entry page="2" page_label="ii" name="Copyright"/>
<entry page="3" page_label="iii" name="Brief Contents"/>
<entry page="5" page_label="v" name="Contents"/>
<entry page="9" page_label="1" name="About the Authors"/>
<entry page="10" page_label="2" name="Acknowledgments"/>
<entry page="11" page_label="3" name="Dedication"/>
<entry page="12" page_label="4" name="Preface"/>
</entry>
<entry page="15" page_label="7" name="Introduction to edX" chapter="1">
<entry page="15" page_label="7" name="edX in the Modern World"/>
<entry page="18" page_label="10" name="The edX Method"/>
<entry page="18" page_label="10" name="A Description of edX"/>
<entry page="29" page_label="21" name="A Brief History of edX"/>
<entry page="51" page_label="43" name="Introduction to edX"/>
<entry page="56" page_label="48" name="Endnotes"/>
</entry>
<entry page="73" page_label="65" name="Art and Photo Credits" chapter="30">
<entry page="73" page_label="65" name="Molecular Models"/>
<entry page="73" page_label="65" name="Photo Credits"/>
</entry>
<entry page="77" page_label="69" name="Index" />
</table_of_contents>
Linking from Content
--------------------
It is possible to add links to specific pages in a textbook by using a URL that encodes the index of the textbook and the page number. The URL is of the form `/course/book/${bookindex}/$page}`. If the page is omitted from the URL, the first page is assumed.
You can use a `customtag` to create a template for such links. For example, you can create a `book` template in the `customtag` directory, containing:
.. code-block:: xml
<img src="/static/images/icons/textbook_icon.png"/> More information given in <a href="/course/book/${book}/${page}">the text</a>.
The course content can then link to page 25 using the `customtag` element:
.. code-block:: xml
<customtag book="0" page="25" impl="book"/>
TBD.
PDF-based Textbooks PDF-based Textbooks
^^^^^^^^^^^^^^^^^^^ ===================
Configuration
-------------
PDF-based textbooks are configured at the course level in the policy file. The JSON markup consists of an array of maps, with each map corresponding to a separate textbook. There are two styles to presenting PDF-based material. The first way is as a single PDF on a tab, which requires only a tab title and a URL for configuration. A second way permits the display of multiple PDFs that should be displayed together on a single view. For this view, a side panel of links is available on the left, allowing selection of a particular PDF to view. PDF-based textbooks are configured at the course level in the policy file. The JSON markup consists of an array of maps, with each map corresponding to a separate textbook. There are two styles to presenting PDF-based material. The first way is as a single PDF on a tab, which requires only a tab title and a URL for configuration. A second way permits the display of multiple PDFs that should be displayed together on a single view. For this view, a side panel of links is available on the left, allowing selection of a particular PDF to view.
...@@ -566,20 +635,51 @@ PDF-based textbooks are configured at the course level in the policy file. The ...@@ -566,20 +635,51 @@ PDF-based textbooks are configured at the course level in the policy file. The
"pdf_textbooks": [ "pdf_textbooks": [
{"tab_title": "Textbook 1", {"tab_title": "Textbook 1",
"url": "https://www.example.com/book1.pdf" }, "url": "https://www.example.com/thiscourse/book1/book1.pdf" },
{"tab_title": "Textbook 2", {"tab_title": "Textbook 2",
"chapters": [ "chapters": [
{ "title": "Chapter 1", "url": "https://www.example.com/Chapter1.pdf" }, { "title": "Chapter 1", "url": "https://www.example.com/thiscourse/book2/Chapter1.pdf" },
{ "title": "Chapter 2", "url": "https://www.example.com/Chapter2.pdf" }, { "title": "Chapter 2", "url": "https://www.example.com/thiscourse/book2/Chapter2.pdf" },
{ "title": "Chapter 3", "url": "https://www.example.com/Chapter3.pdf" }, { "title": "Chapter 3", "url": "https://www.example.com/thiscourse/book2/Chapter3.pdf" },
{ "title": "Chapter 4", "url": "https://www.example.com/Chapter4.pdf" }, { "title": "Chapter 4", "url": "https://www.example.com/thiscourse/book2/Chapter4.pdf" },
{ "title": "Chapter 5", "url": "https://www.example.com/Chapter5.pdf" }, { "title": "Chapter 5", "url": "https://www.example.com/thiscourse/book2/Chapter5.pdf" },
{ "title": "Chapter 6", "url": "https://www.example.com/Chapter6.pdf" }, { "title": "Chapter 6", "url": "https://www.example.com/thiscourse/book2/Chapter6.pdf" },
{ "title": "Chapter 7", "url": "https://www.example.com/Chapter7.pdf" } { "title": "Chapter 7", "url": "https://www.example.com/thiscourse/book2/Chapter7.pdf" }
] ]
} }
] ]
Some notes:
* It is not a good idea to include a top-level URL and chapter-level URLs in the same textbook configuration.
Linking from Content
--------------------
It is possible to add links to specific pages in a textbook by using a URL that encodes the index of the textbook, the chapter (if chapters are used), and the page number. For a book with no chapters, the URL is of the form `/course/pdfbook/${bookindex}/$page}`. For a book with chapters, use `/course/pdfbook/${bookindex}/chapter/${chapter}/${page}`. If the page is omitted from the URL, the first page is assumed.
For example, for the book with no chapters configured above, page 25 can be reached using the URL `/course/pdfbook/0/25`. Reaching page 19 in the third chapter of the second book is accomplished with `/course/pdfbook/1/chapter/3/19`.
You can use a `customtag` to create a template for such links. For example, you can create a `pdfbook` template in the `customtag` directory, containing:
.. code-block:: xml
<img src="/static/images/icons/textbook_icon.png"/> More information given in <a href="/course/pdfbook/${book}/${page}">the text</a>.
And a `pdfchapter` template containing:
.. code-block:: xml
<img src="/static/images/icons/textbook_icon.png"/> More information given in <a href="/course/pdfbook/${book}/chapter/${chapter}/${page}">the text</a>.
The example pages can then be linked using the `customtag` element:
.. code-block:: xml
<customtag book="0" page="25" impl="pdfbook"/>
<customtag book="1" chapter="3" page="19" impl="pdfchapter"/>
************************************* *************************************
Other file locations (info and about) Other file locations (info and about)
************************************* *************************************
......
...@@ -83,9 +83,58 @@ the slider. ...@@ -83,9 +83,58 @@ the slider.
If no targets are provided, then a draggable can be dragged and placed anywhere If no targets are provided, then a draggable can be dragged and placed anywhere
on the base image. on the base image.
correct answer format Targets on draggables
--------------------- ---------------------
Sometimes it is not enough to have targets only on the base image, and all of the
draggables on these targets. If a complex problem exists where a draggable must
become itself a target (or many targets), then the following extended syntax
can be used: ::
<draggable {attribute list}>
<target {attribute list} />
<target {attribute list} />
<target {attribute list} />
...
</draggable>
The attribute list in the tags above ('draggable' and 'target') is the same as for
normal 'draggable' and 'target' tags. The only difference is when you will be
specifying inner target position coordinates. Using the 'x' and 'y' attributes you
are setting the offset of the inner target from the upper-left corner of the
parent draggable (that contains the inner target).
Limitations of targets on draggables
------------------------------------
1.) Currently there is a limitation to the level of nesting of targets.
Even though you can pile up a large number of draggables on targets that themselves
are on draggables, the Drag and Drop instance will be graded only in the case if
there is a maximum of two levels of targets. The first level are the "base" targets.
They are attached to the base image. The second level are the targets defined on
draggables.
2.) Another limitation is that the target bounds are not checked against
other targets.
For now, it is the responsibility of the person who is constructing the course
material to make sure that there is no overlapping of targets. It is also preferable
that targets on draggables are smaller than the actual parent draggable. Technically
this is not necessary, but from the usability perspective it is desirable.
3.) You can have targets on draggables only in the case when there are base targets
defined (base targets are attached to the base image).
If you do not have base targets, then you can only have a single level of nesting
(draggables on the base image). In this case the client side will be reporting (x,y)
positions of each draggables on the base image.
Correct answer format
---------------------
(NOTE: For specifying answers for targets on draggables please see next section.)
There are two correct answer formats: short and long There are two correct answer formats: short and long
If short from correct answer is mapping of 'draggable_id' to 'target_id':: If short from correct answer is mapping of 'draggable_id' to 'target_id'::
...@@ -180,7 +229,7 @@ Rules are: exact, anyof, unordered_equal, anyof+number, unordered_equal+number ...@@ -180,7 +229,7 @@ Rules are: exact, anyof, unordered_equal, anyof+number, unordered_equal+number
'rule': 'unordered_equal' 'rule': 'unordered_equal'
}] }]
- And sometimes you want to allow drag only two 'b' draggables, in these case you sould use 'anyof+number' of 'unordered_equal+number' rule:: - And sometimes you want to allow drag only two 'b' draggables, in these case you should use 'anyof+number' of 'unordered_equal+number' rule::
correct_answer = [ correct_answer = [
{ {
...@@ -204,6 +253,54 @@ for same number of draggables, anyof is equal to unordered_equal ...@@ -204,6 +253,54 @@ for same number of draggables, anyof is equal to unordered_equal
If we have can_reuse=true, than one must use only long form of correct answer. If we have can_reuse=true, than one must use only long form of correct answer.
Answer format for targets on draggables
---------------------------------------
As with the cases described above, an answer must provide precise positioning for
each draggable (on which targets it must reside). In the case when a draggable must
be placed on a target that itself is on a draggable, then the answer must contain
the chain of target-draggable-target. It is best to understand this on an example.
Suppose we have three draggables - 'up', 's', and 'p'. Draggables 's', and 'p' have targets
on themselves. More specifically, 'p' has three targets - '1', '2', and '3'. The first
requirement is that 's', and 'p' are positioned on specific targets on the base image.
The second requirement is that draggable 'up' is positioned on specific targets of
draggable 'p'. Below is an excerpt from a problem.::
<draggable id="up" icon="/static/images/images_list/lcao-mo/up.png" can_reuse="true" />
<draggable id="s" icon="/static/images/images_list/lcao-mo/orbital_single.png" label="s orbital" can_reuse="true" >
<target id="1" x="0" y="0" w="32" h="32"/>
</draggable>
<draggable id="p" icon="/static/images/images_list/lcao-mo/orbital_triple.png" can_reuse="true" label="p orbital" >
<target id="1" x="0" y="0" w="32" h="32"/>
<target id="2" x="34" y="0" w="32" h="32"/>
<target id="3" x="68" y="0" w="32" h="32"/>
</draggable>
...
correct_answer = [
{
'draggables': ['p'],
'targets': ['p-left-target', 'p-right-target'],
'rule': 'unordered_equal'
},
{
'draggables': ['s'],
'targets': ['s-left-target', 's-right-target'],
'rule': 'unordered_equal'
},
{
'draggables': ['up'],
'targets': ['p-left-target[p][1]', 'p-left-target[p][2]', 'p-right-target[p][2]', 'p-right-target[p][3]',],
'rule': 'unordered_equal'
}
]
Note that it is a requirement to specify rules for all draggables, even if some draggable gets included
in more than one chain.
Grading logic Grading logic
------------- -------------
...@@ -321,3 +418,8 @@ Draggables can be reused ...@@ -321,3 +418,8 @@ Draggables can be reused
------------------------ ------------------------
.. literalinclude:: drag-n-drop-demo2.xml .. literalinclude:: drag-n-drop-demo2.xml
Examples of targets on draggables
------------------------
.. literalinclude:: drag-n-drop-demo3.xml
...@@ -3,4 +3,3 @@ ...@@ -3,4 +3,3 @@
-e git://github.com/MITx/django-pipeline.git#egg=django-pipeline -e git://github.com/MITx/django-pipeline.git#egg=django-pipeline
-e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki -e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki
-e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev
-e git://github.com/MITx/dogapi.git@003a4fc9#egg=dogapi
...@@ -16,7 +16,6 @@ from django.views.decorators.csrf import csrf_exempt ...@@ -16,7 +16,6 @@ from django.views.decorators.csrf import csrf_exempt
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
from capa.xqueue_interface import XQueueInterface from capa.xqueue_interface import XQueueInterface
from capa.chem import chemcalc
from courseware.access import has_access from courseware.access import has_access
from mitxmako.shortcuts import render_to_string from mitxmako.shortcuts import render_to_string
from models import StudentModule, StudentModuleCache from models import StudentModule, StudentModuleCache
...@@ -501,7 +500,7 @@ def modx_dispatch(request, dispatch, location, course_id): ...@@ -501,7 +500,7 @@ def modx_dispatch(request, dispatch, location, course_id):
if instance is None: if instance is None:
# Either permissions just changed, or someone is trying to be clever # Either permissions just changed, or someone is trying to be clever
# and load something they shouldn't have access to. # and load something they shouldn't have access to.
log.debug("No module {0} for user {1}--access denied?".format(location, user)) log.debug("No module {0} for user {1}--access denied?".format(location, request.user))
raise Http404 raise Http404
instance_module = get_instance_module(course_id, request.user, instance, student_module_cache) instance_module = get_instance_module(course_id, request.user, instance, student_module_cache)
...@@ -559,42 +558,6 @@ def modx_dispatch(request, dispatch, location, course_id): ...@@ -559,42 +558,6 @@ def modx_dispatch(request, dispatch, location, course_id):
return HttpResponse(ajax_return) return HttpResponse(ajax_return)
def preview_chemcalc(request):
"""
Render an html preview of a chemical formula or equation. The fact that
this is here is a bit of hack. See the note in lms/urls.py about why it's
here. (Victor is to blame.)
request should be a GET, with a key 'formula' and value 'some formula string'.
Returns a json dictionary:
{
'preview' : 'the-preview-html' or ''
'error' : 'the-error' or ''
}
"""
if request.method != "GET":
raise Http404
result = {'preview': '',
'error': ''}
formula = request.GET.get('formula')
if formula is None:
result['error'] = "No formula specified."
return HttpResponse(json.dumps(result))
try:
result['preview'] = chemcalc.render_to_html(formula)
except pyparsing.ParseException as p:
result['error'] = "Couldn't parse formula: {0}".format(p)
except Exception:
# this is unexpected, so log
log.warning("Error while previewing chemical formula", exc_info=True)
result['error'] = "Error while rendering preview"
return HttpResponse(json.dumps(result))
def get_score_bucket(grade, max_grade): def get_score_bucket(grade, max_grade):
""" """
......
from django.test import TestCase
from mock import MagicMock
import courseware.tabs as tabs
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
class ProgressTestCase(TestCase):
def setUp(self):
self.mockuser1 = MagicMock()
self.mockuser0 = MagicMock()
self.course = MagicMock()
self.mockuser1.is_authenticated.return_value = True
self.mockuser0.is_authenticated.return_value = False
self.course.id = 'edX/full/6.002_Spring_2012'
self.tab = {'name': 'same'}
self.active_page1 = 'progress'
self.active_page0 = 'stagnation'
def test_progress(self):
self.assertEqual(tabs._progress(self.tab, self.mockuser0, self.course,
self.active_page0), [])
self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course,
self.active_page1)[0].name, 'same')
self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course,
self.active_page1)[0].link,
reverse('progress', args = [self.course.id]))
self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course,
self.active_page0)[0].is_active, False)
self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course,
self.active_page1)[0].is_active, True)
class WikiTestCase(TestCase):
def setUp(self):
self.user = MagicMock()
self.course = MagicMock()
self.course.id = 'edX/full/6.002_Spring_2012'
self.tab = {'name': 'same'}
self.active_page1 = 'wiki'
self.active_page0 = 'miki'
@override_settings(WIKI_ENABLED=True)
def test_wiki_enabled(self):
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page1)[0].name,
'same')
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page1)[0].link,
reverse('course_wiki', args=[self.course.id]))
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page1)[0].is_active,
True)
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page0)[0].is_active,
False)
@override_settings(WIKI_ENABLED=False)
def test_wiki_enabled_false(self):
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page1), [])
class ExternalLinkTestCase(TestCase):
def setUp(self):
self.user = MagicMock()
self.course = MagicMock()
self.tabby = {'name': 'same', 'link': 'blink'}
self.active_page0 = None
self.active_page00 = True
def test_external_link(self):
self.assertEqual(tabs._external_link(self.tabby, self.user,
self.course, self.active_page0)[0].name,
'same')
self.assertEqual(tabs._external_link(self.tabby, self.user,
self.course, self.active_page0)[0].link,
'blink')
self.assertEqual(tabs._external_link(self.tabby, self.user,
self.course, self.active_page0)[0].is_active,
False)
self.assertEqual(tabs._external_link(self.tabby, self.user,
self.course, self.active_page00)[0].is_active,
False)
class StaticTabTestCase(TestCase):
def setUp(self):
self.user = MagicMock()
self.course = MagicMock()
self.tabby = {'name': 'same', 'url_slug': 'schmug'}
self.course.id = 'edX/full/6.002_Spring_2012'
self.active_page1 = 'static_tab_schmug'
self.active_page0 = 'static_tab_schlug'
def test_static_tab(self):
self.assertEqual(tabs._static_tab(self.tabby, self.user,
self.course, self.active_page1)[0].name,
'same')
self.assertEqual(tabs._static_tab(self.tabby, self.user,
self.course, self.active_page1)[0].link,
reverse('static_tab', args = [self.course.id,
self.tabby['url_slug']]))
self.assertEqual(tabs._static_tab(self.tabby, self.user,
self.course, self.active_page1)[0].is_active,
True)
self.assertEqual(tabs._static_tab(self.tabby, self.user,
self.course, self.active_page0)[0].is_active,
False)
class TextbooksTestCase(TestCase):
def setUp(self):
self.mockuser1 = MagicMock()
self.mockuser0 = MagicMock()
self.course = MagicMock()
self.tab = MagicMock()
A = MagicMock()
T = MagicMock()
self.mockuser1.is_authenticated.return_value = True
self.mockuser0.is_authenticated.return_value = False
self.course.id = 'edX/full/6.002_Spring_2012'
self.active_page0 = 'textbook/0'
self.active_page1 = 'textbook/1'
self.active_pageX = 'you_shouldnt_be_seein_this'
A.title = 'Algebra'
T.title = 'Topology'
self.course.textbooks = [A, T]
@override_settings(MITX_FEATURES={'ENABLE_TEXTBOOK': True})
def test_textbooks1(self):
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page0)[0].name,
'Algebra')
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page0)[0].link,
reverse('book', args=[self.course.id, 0]))
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page0)[0].is_active,
True)
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_pageX)[0].is_active,
False)
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page1)[1].name,
'Topology')
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page1)[1].link,
reverse('book', args=[self.course.id, 1]))
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page1)[1].is_active,
True)
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_pageX)[1].is_active,
False)
@override_settings(MITX_FEATURES={'ENABLE_TEXTBOOK': False})
def test_textbooks0(self):
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_pageX), [])
self.assertEqual(tabs._textbooks(self.tab, self.mockuser0,
self.course, self.active_pageX), [])
class KeyCheckerTestCase(TestCase):
def setUp(self):
self.expected_keys1 = ['a', 'b']
self.expected_keys0 = ['a', 'v', 'g']
self.dictio = {'a': 1, 'b': 2, 'c': 3}
def test_key_checker(self):
self.assertIsNone(tabs.key_checker(self.expected_keys1)(self.dictio))
self.assertRaises(tabs.InvalidTabsException,
tabs.key_checker(self.expected_keys0), self.dictio)
class NullValidatorTestCase(TestCase):
def setUp(self):
self.d = {}
def test_null_validator(self):
self.assertIsNone(tabs.null_validator(self.d))
class ValidateTabsTestCase(TestCase):
def setUp(self):
self.courses = [MagicMock() for i in range(0,5)]
self.courses[0].tabs = None
self.courses[1].tabs = [{'type':'courseware'}, {'type': 'fax'}]
self.courses[2].tabs = [{'type':'shadow'}, {'type': 'course_info'}]
self.courses[3].tabs = [{'type':'courseware'},{'type':'course_info', 'name': 'alice'},
{'type': 'wiki', 'name':'alice'}, {'type':'discussion', 'name': 'alice'},
{'type':'external_link', 'name': 'alice', 'link':'blink'},
{'type':'textbooks'}, {'type':'progress', 'name': 'alice'},
{'type':'static_tab', 'name':'alice', 'url_slug':'schlug'},
{'type': 'staff_grading'}]
self.courses[4].tabs = [{'type':'courseware'},{'type': 'course_info'}, {'type': 'flying'}]
def test_validate_tabs(self):
self.assertIsNone(tabs.validate_tabs(self.courses[0]))
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[1])
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[2])
self.assertIsNone(tabs.validate_tabs(self.courses[3]))
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[4])
import django_comment_client.models as models
import django_comment_client.permissions as permissions
from django.test import TestCase
class RoleClassTestCase(TestCase):
def setUp(self):
# For course ID, syntax edx/classname/classdate is important
# because xmodel.course_module.id_to_location looks for a string to split
self.course_id = "edX/toy/2012_Fall"
self.student_role = models.Role.objects.get_or_create(name="Student", \
course_id=self.course_id)[0]
self.student_role.add_permission("delete_thread")
self.student_2_role = models.Role.objects.get_or_create(name="Student", \
course_id=self.course_id)[0]
self.TA_role = models.Role.objects.get_or_create(name="Community TA",\
course_id=self.course_id)[0]
self.course_id_2 = "edx/6.002x/2012_Fall"
self.TA_role_2 = models.Role.objects.get_or_create(name="Community TA",\
course_id=self.course_id_2)[0]
class Dummy():
def render_template():
pass
d = {"data": {
"textbooks": [],
'wiki_slug': True,
}
}
def testHasPermission(self):
# Whenever you add a permission to student_role,
# Roles with the same FORUM_ROLE in same class also receives the same
# permission.
# Is this desirable behavior?
self.assertTrue(self.student_role.has_permission("delete_thread"))
self.assertTrue(self.student_2_role.has_permission("delete_thread"))
self.assertFalse(self.TA_role.has_permission("delete_thread"))
def testInheritPermissions(self):
self.TA_role.inherit_permissions(self.student_role)
self.assertTrue(self.TA_role.has_permission("delete_thread"))
# Despite being from 2 different courses, TA_role_2 can still inherit
# permissions from TA_role without error
self.TA_role_2.inherit_permissions(self.TA_role)
class PermissionClassTestCase(TestCase):
def setUp(self):
self.permission = permissions.Permission.objects.get_or_create(name="test")[0]
def testUnicode(self):
self.assertEqual(str(self.permission), "test")
...@@ -3,26 +3,40 @@ import random ...@@ -3,26 +3,40 @@ import random
import collections import collections
from django.test import TestCase from django.test import TestCase
from mock import MagicMock
from django.test.utils import override_settings
import django.core.urlresolvers as urlresolvers
import django_comment_client.mustache_helpers as mustache_helpers import django_comment_client.mustache_helpers as mustache_helpers
#########################################################################################
class PluralizeTestCase(TestCase):
def test_pluralize(self): class PluralizeTest(TestCase):
self.text1 = '0 goat'
self.text2 = '1 goat'
self.text3 = '7 goat'
self.content = 'unused argument'
self.assertEqual(mustache_helpers.pluralize(self.content, self.text1), 'goats')
self.assertEqual(mustache_helpers.pluralize(self.content, self.text2), 'goat')
self.assertEqual(mustache_helpers.pluralize(self.content, self.text3), 'goats')
def setUp(self):
self.text1 = '0 goat'
self.text2 = '1 goat'
self.text3 = '7 goat'
self.content = 'unused argument'
class CloseThreadTextTestCase(TestCase): def test_pluralize(self):
self.assertEqual(mustache_helpers.pluralize(self.content, self.text1), 'goats')
self.assertEqual(mustache_helpers.pluralize(self.content, self.text2), 'goat')
self.assertEqual(mustache_helpers.pluralize(self.content, self.text3), 'goats')
#########################################################################################
class CloseThreadTextTest(TestCase):
def setUp(self):
self.contentClosed = {'closed': True}
self.contentOpen = {'closed': False}
def test_close_thread_text(self):
self.assertEqual(mustache_helpers.close_thread_text(self.contentClosed), 'Re-open thread')
self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread')
#########################################################################################
def test_close_thread_text(self):
self.contentClosed = {'closed': True}
self.contentOpen = {'closed': False}
self.assertEqual(mustache_helpers.close_thread_text(self.contentClosed), 'Re-open thread')
self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread')
...@@ -130,7 +130,7 @@ def save_scores(user, puzzle_scores): ...@@ -130,7 +130,7 @@ def save_scores(user, puzzle_scores):
current_score=current_score, current_score=current_score,
best_score=best_score, best_score=best_score,
score_version=score_version) score_version=score_version)
obj.save() obj.save()
score_responses.append({'PuzzleID': puzzle_id, score_responses.append({'PuzzleID': puzzle_id,
'Status': 'Success'}) 'Status': 'Success'})
......
from dogapi import dog_http_api, dog_stats_api
from django.conf import settings
if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True)
<div class="annotatable-wrapper">
<div class="annotatable-header">
% if display_name is not UNDEFINED and display_name is not None:
<div class="annotatable-title">${display_name}</div>
% endif
</div>
% if instructions_html is not UNDEFINED and instructions_html is not None:
<div class="annotatable-section shaded">
<div class="annotatable-section-title">
Instructions
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">Collapse Instructions</a>
</div>
<div class="annotatable-section-body annotatable-instructions">
${instructions_html}
</div>
</div>
% endif
<div class="annotatable-section">
<div class="annotatable-section-title">
Guided Discussion
<a class="annotatable-toggle annotatable-toggle-annotations" href="javascript:void(0)">Hide Annotations</a>
</div>
<div class="annotatable-section-body annotatable-content">
${content_html}
</div>
</div>
</div>
...@@ -3,6 +3,9 @@ from django.conf.urls import patterns, include, url ...@@ -3,6 +3,9 @@ from django.conf.urls import patterns, include, url
from django.contrib import admin from django.contrib import admin
from django.conf.urls.static import static from django.conf.urls.static import static
from django.views.generic import RedirectView from django.views.generic import RedirectView
from . import one_time_startup
import django.contrib.auth.views import django.contrib.auth.views
# Uncomment the next two lines to enable the admin: # Uncomment the next two lines to enable the admin:
...@@ -224,14 +227,6 @@ if settings.COURSEWARE_ENABLED: ...@@ -224,14 +227,6 @@ if settings.COURSEWARE_ENABLED:
'courseware.module_render.modx_dispatch', 'courseware.module_render.modx_dispatch',
name='modx_dispatch'), name='modx_dispatch'),
# TODO (vshnayder): This is a hack. It creates a direct connection from
# the LMS to capa functionality, and really wants to go through the
# input types system so that previews can be context-specific.
# Unfortunately, we don't have time to think through the right way to do
# that (and implement it), and it's not a terrible thing to provide a
# generic chemical-equation rendering service.
url(r'^preview/chemcalc', 'courseware.module_render.preview_chemcalc',
name='preview_chemcalc'),
# Software Licenses # Software Licenses
......
...@@ -58,3 +58,4 @@ ipython==0.13.1 ...@@ -58,3 +58,4 @@ ipython==0.13.1
xmltodict==0.4.1 xmltodict==0.4.1
paramiko==1.9.0 paramiko==1.9.0
Pillow==1.7.8 Pillow==1.7.8
dogapi==1.2.1
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment