resolving local merge with master
This commit is contained in:
@@ -34,6 +34,7 @@ load-plugins=
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once).
|
||||
disable=
|
||||
# C0301: Line too long
|
||||
# W0141: Used builtin function 'map'
|
||||
# W0142: Used * or ** magic
|
||||
# R0201: Method could be a function
|
||||
@@ -42,7 +43,7 @@ disable=
|
||||
# R0903: Too few public methods (1/2)
|
||||
# R0904: Too many public methods
|
||||
# R0913: Too many arguments
|
||||
W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0913
|
||||
C0301,W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0913
|
||||
|
||||
|
||||
[REPORTS]
|
||||
@@ -138,7 +139,7 @@ bad-names=foo,bar,baz,toto,tutu,tata
|
||||
|
||||
# Regular expression which should only match functions or classes name which do
|
||||
# not require a docstring
|
||||
no-docstring-rgx=__.*__
|
||||
no-docstring-rgx=(__.*__|test_.*)
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Feature: Advanced (manual) course policy
|
||||
In order to specify course policy settings for which no custom user interface exists
|
||||
I want to be able to manually enter JSON key/value pairs
|
||||
I want to be able to manually enter JSON key /value pairs
|
||||
|
||||
Scenario: A course author sees default advanced settings
|
||||
Given I have opened a new course in Studio
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
import time
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from nose.tools import assert_true, assert_false, assert_equal
|
||||
|
||||
@@ -18,13 +19,14 @@ DISPLAY_NAME_KEY = "display_name"
|
||||
DISPLAY_NAME_VALUE = '"Robot Super Course"'
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
@step('I select the Advanced Settings$')
|
||||
def i_select_advanced_settings(step):
|
||||
expand_icon_css = 'li.nav-course-settings i.icon-expand'
|
||||
if world.browser.is_element_present_by_css(expand_icon_css):
|
||||
css_click(expand_icon_css)
|
||||
world.css_click(expand_icon_css)
|
||||
link_css = 'li.nav-course-settings-advanced a'
|
||||
css_click(link_css)
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step('I am on the Advanced Course Settings page in Studio$')
|
||||
@@ -35,24 +37,8 @@ def i_am_on_advanced_course_settings(step):
|
||||
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(step, 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)
|
||||
time.sleep(float(1))
|
||||
css_click_at(css)
|
||||
|
||||
# is_invisible is not returning a boolean, not working
|
||||
# try:
|
||||
# css_click_at(css)
|
||||
# wait_for(is_invisible)
|
||||
# except WebDriverException, e:
|
||||
# css_click_at(css)
|
||||
# wait_for(is_invisible)
|
||||
world.css_click_at(css)
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key$')
|
||||
@@ -61,7 +47,7 @@ def edit_the_value_of_a_policy_key(step):
|
||||
It is hard to figure out how to get into the CodeMirror
|
||||
area, so cheat and do it from the policy key field :)
|
||||
"""
|
||||
e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
|
||||
e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
|
||||
e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X')
|
||||
|
||||
|
||||
@@ -85,7 +71,7 @@ def i_see_default_advanced_settings(step):
|
||||
|
||||
@step('the settings are alphabetized$')
|
||||
def they_are_alphabetized(step):
|
||||
key_elements = css_find(KEY_CSS)
|
||||
key_elements = world.css_find(KEY_CSS)
|
||||
all_keys = []
|
||||
for key in key_elements:
|
||||
all_keys.append(key.value)
|
||||
@@ -118,13 +104,13 @@ def assert_policy_entries(expected_keys, expected_values):
|
||||
for counter in range(len(expected_keys)):
|
||||
index = get_index_of(expected_keys[counter])
|
||||
assert_false(index == -1, "Could not find key: " + expected_keys[counter])
|
||||
assert_equal(expected_values[counter], css_find(VALUE_CSS)[index].value, "value is incorrect")
|
||||
assert_equal(expected_values[counter], world.css_find(VALUE_CSS)[index].value, "value is incorrect")
|
||||
|
||||
|
||||
def get_index_of(expected_key):
|
||||
for counter in range(len(css_find(KEY_CSS))):
|
||||
for counter in range(len(world.css_find(KEY_CSS))):
|
||||
# Sometimes get stale reference if I hold on to the array of elements
|
||||
key = css_find(KEY_CSS)[counter].value
|
||||
key = world.css_find(KEY_CSS)[counter].value
|
||||
if key == expected_key:
|
||||
return counter
|
||||
|
||||
@@ -133,14 +119,14 @@ def get_index_of(expected_key):
|
||||
|
||||
def get_display_name_value():
|
||||
index = get_index_of(DISPLAY_NAME_KEY)
|
||||
return css_find(VALUE_CSS)[index].value
|
||||
return world.css_find(VALUE_CSS)[index].value
|
||||
|
||||
|
||||
def change_display_name_value(step, new_value):
|
||||
e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
|
||||
e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
|
||||
display_name = get_display_name_value()
|
||||
for count in range(len(display_name)):
|
||||
e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE)
|
||||
# Must delete "" before typing the JSON value
|
||||
e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value)
|
||||
press_the_notification_button(step, "Save")
|
||||
press_the_notification_button(step, "Save")
|
||||
|
||||
24
cms/djangoapps/contentstore/features/checklists.feature
Normal file
24
cms/djangoapps/contentstore/features/checklists.feature
Normal file
@@ -0,0 +1,24 @@
|
||||
Feature: Course checklists
|
||||
|
||||
Scenario: A course author sees checklists defined by edX
|
||||
Given I have opened a new course in Studio
|
||||
When I select Checklists from the Tools menu
|
||||
Then I see the four default edX checklists
|
||||
|
||||
Scenario: A course author can mark tasks as complete
|
||||
Given I have opened Checklists
|
||||
Then I can check and uncheck tasks in a checklist
|
||||
And They are correctly selected after I reload the page
|
||||
|
||||
Scenario: A task can link to a location within Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to the course outline
|
||||
Then I am brought to the course outline page
|
||||
And I press the browser back button
|
||||
Then I am brought back to the course outline in the correct state
|
||||
|
||||
Scenario: A task can link to a location outside Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to help page
|
||||
Then I am brought to the help page in a new window
|
||||
|
||||
123
cms/djangoapps/contentstore/features/checklists.py
Normal file
123
cms/djangoapps/contentstore/features/checklists.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true, assert_equal
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.common.exceptions import StaleElementReferenceException
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I select Checklists from the Tools menu$')
|
||||
def i_select_checklists(step):
|
||||
expand_icon_css = 'li.nav-course-tools i.icon-expand'
|
||||
if world.browser.is_element_present_by_css(expand_icon_css):
|
||||
world.css_click(expand_icon_css)
|
||||
link_css = 'li.nav-course-tools-checklists a'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step('I have opened Checklists$')
|
||||
def i_have_opened_checklists(step):
|
||||
step.given('I have opened a new course in Studio')
|
||||
step.given('I select Checklists from the Tools menu')
|
||||
|
||||
|
||||
@step('I see the four default edX checklists$')
|
||||
def i_see_default_checklists(step):
|
||||
checklists = world.css_find('.checklist-title')
|
||||
assert_equal(4, len(checklists))
|
||||
assert_true(checklists[0].text.endswith('Getting Started With Studio'))
|
||||
assert_true(checklists[1].text.endswith('Draft a Rough Course Outline'))
|
||||
assert_true(checklists[2].text.endswith("Explore edX\'s Support Tools"))
|
||||
assert_true(checklists[3].text.endswith('Draft Your Course About Page'))
|
||||
|
||||
|
||||
@step('I can check and uncheck tasks in a checklist$')
|
||||
def i_can_check_and_uncheck_tasks(step):
|
||||
# Use the 2nd checklist as a reference
|
||||
verifyChecklist2Status(0, 7, 0)
|
||||
toggleTask(1, 0)
|
||||
verifyChecklist2Status(1, 7, 14)
|
||||
toggleTask(1, 3)
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
toggleTask(1, 6)
|
||||
verifyChecklist2Status(3, 7, 43)
|
||||
toggleTask(1, 3)
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
|
||||
|
||||
@step('They are correctly selected after I reload the page$')
|
||||
def tasks_correctly_selected_after_reload(step):
|
||||
reload_the_page(step)
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
# verify that task 7 is still selected by toggling its checkbox state and making sure that it deselects
|
||||
toggleTask(1, 6)
|
||||
verifyChecklist2Status(1, 7, 14)
|
||||
|
||||
|
||||
@step('I select a link to the course outline$')
|
||||
def i_select_a_link_to_the_course_outline(step):
|
||||
clickActionLink(1, 0, 'Edit Course Outline')
|
||||
|
||||
|
||||
@step('I am brought to the course outline page$')
|
||||
def i_am_brought_to_course_outline(step):
|
||||
assert_equal('Course Outline', world.css_find('.outline .title-1')[0].text)
|
||||
assert_equal(1, len(world.browser.windows))
|
||||
|
||||
|
||||
@step('I am brought back to the course outline in the correct state$')
|
||||
def i_am_brought_back_to_course_outline(step):
|
||||
step.given('I see the four default edX checklists')
|
||||
# In a previous step, we selected (1, 0) in order to click the 'Edit Course Outline' link.
|
||||
# Make sure the task is still showing as selected (there was a caching bug with the collection).
|
||||
verifyChecklist2Status(1, 7, 14)
|
||||
|
||||
|
||||
@step('I select a link to help page$')
|
||||
def i_select_a_link_to_the_help_page(step):
|
||||
clickActionLink(2, 0, 'Visit Studio Help')
|
||||
|
||||
|
||||
@step('I am brought to the help page in a new window$')
|
||||
def i_am_brought_to_help_page_in_new_window(step):
|
||||
step.given('I see the four default edX checklists')
|
||||
windows = world.browser.windows
|
||||
assert_equal(2, len(windows))
|
||||
world.browser.switch_to_window(windows[1])
|
||||
assert_equal('http://help.edge.edx.org/', world.browser.url)
|
||||
|
||||
|
||||
|
||||
|
||||
############### HELPER METHODS ####################
|
||||
def verifyChecklist2Status(completed, total, percentage):
|
||||
def verify_count(driver):
|
||||
try:
|
||||
statusCount = world.css_find('#course-checklist1 .status-count').first
|
||||
return statusCount.text == str(completed)
|
||||
except StaleElementReferenceException:
|
||||
return False
|
||||
|
||||
world.wait_for(verify_count)
|
||||
assert_equal(str(total), world.css_find('#course-checklist1 .status-amount').first.text)
|
||||
# Would like to check the CSS width, but not sure how to do that.
|
||||
assert_equal(str(percentage), world.css_find('#course-checklist1 .viz-checklist-status-value .int').first.text)
|
||||
|
||||
|
||||
def toggleTask(checklist, task):
|
||||
world.css_click('#course-checklist' + str(checklist) +'-task' + str(task))
|
||||
|
||||
|
||||
def clickActionLink(checklist, task, actionText):
|
||||
# toggle checklist item to make sure that the link button is showing
|
||||
toggleTask(checklist, task)
|
||||
action_link = world.css_find('#course-checklist' + str(checklist) + ' a')[task]
|
||||
|
||||
# text will be empty initially, wait for it to populate
|
||||
def verify_action_link_text(driver):
|
||||
return action_link.text == actionText
|
||||
|
||||
world.wait_for(verify_action_link_text)
|
||||
action_link.click()
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from lettuce.django import django_url
|
||||
from nose.tools import assert_true
|
||||
from nose.tools import assert_equal
|
||||
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 xmodule.modulestore.django import _MODULESTORES, modulestore
|
||||
from xmodule.templates import update_templates
|
||||
from auth.authz import get_user_by_email
|
||||
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
import time
|
||||
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
########### STEP HELPERS ##############
|
||||
|
||||
@step('I (?:visit|access|open) the Studio homepage$')
|
||||
def i_visit_the_studio_homepage(step):
|
||||
# To make this go to port 8001, put
|
||||
# LETTUCE_SERVER_PORT = 8001
|
||||
# in your settings.py file.
|
||||
world.browser.visit(django_url('/'))
|
||||
world.visit('/')
|
||||
signin_css = 'a.action-signin'
|
||||
assert world.browser.is_element_present_by_css(signin_css, 10)
|
||||
assert world.is_css_present(signin_css)
|
||||
|
||||
|
||||
@step('I am logged into Studio$')
|
||||
@@ -43,12 +45,12 @@ def i_press_the_category_delete_icon(step, category):
|
||||
css = 'a.delete-button.delete-subsection-button span.delete-icon'
|
||||
else:
|
||||
assert False, 'Invalid category: %s' % category
|
||||
css_click(css)
|
||||
world.css_click(css)
|
||||
|
||||
|
||||
@step('I have opened a new course in Studio$')
|
||||
def i_have_opened_a_new_course(step):
|
||||
clear_courses()
|
||||
world.clear_courses()
|
||||
log_into_studio()
|
||||
create_a_course()
|
||||
|
||||
@@ -74,80 +76,13 @@ def create_studio_user(
|
||||
user_profile = world.UserProfileFactory(user=studio_user)
|
||||
|
||||
|
||||
def flush_xmodule_store():
|
||||
# Flush and initialize the module store
|
||||
# It needs the templates because it creates new records
|
||||
# by cloning from the template.
|
||||
# Note that if your test module gets in some weird state
|
||||
# (though it shouldn't), do this manually
|
||||
# from the bash shell to drop it:
|
||||
# $ mongo test_xmodule --eval "db.dropDatabase()"
|
||||
_MODULESTORES = {}
|
||||
modulestore().collection.drop()
|
||||
update_templates()
|
||||
|
||||
|
||||
def assert_css_with_text(css, text):
|
||||
assert_true(world.browser.is_element_present_by_css(css, 5))
|
||||
assert_equal(world.browser.find_by_css(css).text, text)
|
||||
|
||||
|
||||
def css_click(css):
|
||||
'''
|
||||
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):
|
||||
'''
|
||||
A method to click at x,y coordinates of the element
|
||||
rather than in the center of the element
|
||||
'''
|
||||
e = css_find(css).first
|
||||
e.action_chains.move_to_element_with_offset(e._element, x, y)
|
||||
e.action_chains.click()
|
||||
e.action_chains.perform()
|
||||
|
||||
|
||||
def css_fill(css, value):
|
||||
world.browser.find_by_css(css).first.fill(value)
|
||||
|
||||
|
||||
def css_find(css):
|
||||
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)
|
||||
|
||||
|
||||
def wait_for(func):
|
||||
WebDriverWait(world.browser.driver, 5).until(func)
|
||||
|
||||
|
||||
def id_find(id):
|
||||
return world.browser.find_by_id(id)
|
||||
|
||||
|
||||
def clear_courses():
|
||||
flush_xmodule_store()
|
||||
|
||||
|
||||
def fill_in_course_info(
|
||||
name='Robot Super Course',
|
||||
org='MITx',
|
||||
num='101'):
|
||||
css_fill('.new-course-name', name)
|
||||
css_fill('.new-course-org', org)
|
||||
css_fill('.new-course-number', num)
|
||||
world.css_fill('.new-course-name', name)
|
||||
world.css_fill('.new-course-org', org)
|
||||
world.css_fill('.new-course-number', num)
|
||||
|
||||
|
||||
def log_into_studio(
|
||||
@@ -155,21 +90,22 @@ def log_into_studio(
|
||||
email='robot+studio@edx.org',
|
||||
password='test',
|
||||
is_staff=False):
|
||||
create_studio_user(uname=uname, email=email, is_staff=is_staff)
|
||||
world.browser.cookies.delete()
|
||||
world.browser.visit(django_url('/'))
|
||||
signin_css = 'a.action-signin'
|
||||
world.browser.is_element_present_by_css(signin_css, 10)
|
||||
|
||||
# click the signin button
|
||||
css_click(signin_css)
|
||||
create_studio_user(uname=uname, email=email, is_staff=is_staff)
|
||||
|
||||
world.browser.cookies.delete()
|
||||
world.visit('/')
|
||||
|
||||
signin_css = 'a.action-signin'
|
||||
world.is_css_present(signin_css)
|
||||
world.css_click(signin_css)
|
||||
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill(email)
|
||||
login_form.find_by_name('password').fill(password)
|
||||
login_form.find_by_name('submit').click()
|
||||
|
||||
assert_true(world.browser.is_element_present_by_css('.new-course-button', 5))
|
||||
assert_true(world.is_css_present('.new-course-button'))
|
||||
|
||||
|
||||
def create_a_course():
|
||||
@@ -184,26 +120,37 @@ def create_a_course():
|
||||
world.browser.reload()
|
||||
|
||||
course_link_css = 'span.class-name'
|
||||
css_click(course_link_css)
|
||||
world.css_click(course_link_css)
|
||||
course_title_css = 'span.course-title'
|
||||
assert_true(world.browser.is_element_present_by_css(course_title_css, 5))
|
||||
assert_true(world.is_css_present(course_title_css))
|
||||
|
||||
|
||||
def add_section(name='My Section'):
|
||||
link_css = 'a.new-courseware-section-button'
|
||||
css_click(link_css)
|
||||
world.css_click(link_css)
|
||||
name_css = 'input.new-section-name'
|
||||
save_css = 'input.new-section-name-save'
|
||||
css_fill(name_css, name)
|
||||
css_click(save_css)
|
||||
world.css_fill(name_css, name)
|
||||
world.css_click(save_css)
|
||||
span_css = 'span.section-name-span'
|
||||
assert_true(world.browser.is_element_present_by_css(span_css, 5))
|
||||
assert_true(world.is_css_present(span_css))
|
||||
|
||||
|
||||
def add_subsection(name='Subsection One'):
|
||||
css = 'a.new-subsection-item'
|
||||
css_click(css)
|
||||
world.css_click(css)
|
||||
name_css = 'input.new-subsection-name-input'
|
||||
save_css = 'input.new-subsection-name-save'
|
||||
css_fill(name_css, name)
|
||||
css_click(save_css)
|
||||
world.css_fill(name_css, name)
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
def set_date_and_time(date_css, desired_date, time_css, desired_time):
|
||||
world.css_fill(date_css, desired_date)
|
||||
# hit TAB to get to the time field
|
||||
e = world.css_find(date_css).first
|
||||
e._element.send_keys(Keys.TAB)
|
||||
world.css_fill(time_css, desired_time)
|
||||
e = world.css_find(time_css).first
|
||||
e._element.send_keys(Keys.TAB)
|
||||
time.sleep(float(1))
|
||||
|
||||
25
cms/djangoapps/contentstore/features/course-settings.feature
Normal file
25
cms/djangoapps/contentstore/features/course-settings.feature
Normal file
@@ -0,0 +1,25 @@
|
||||
Feature: Course Settings
|
||||
As a course author, I want to be able to configure my course settings.
|
||||
|
||||
Scenario: User can set course dates
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I set course dates
|
||||
Then I see the set dates on refresh
|
||||
|
||||
Scenario: User can clear previously set course dates (except start date)
|
||||
Given I have set course dates
|
||||
And I clear all the dates except start
|
||||
Then I see cleared dates on refresh
|
||||
|
||||
Scenario: User cannot clear the course start date
|
||||
Given I have set course dates
|
||||
And I clear the course start date
|
||||
Then I receive a warning about course start date
|
||||
And The previously set start date is shown on refresh
|
||||
|
||||
Scenario: User can correct the course start date warning
|
||||
Given I have tried to clear the course start
|
||||
And I have entered a new course start date
|
||||
Then The warning about course start date goes away
|
||||
And My new course start date is shown on refresh
|
||||
165
cms/djangoapps/contentstore/features/course-settings.py
Normal file
165
cms/djangoapps/contentstore/features/course-settings.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
import time
|
||||
|
||||
from nose.tools import assert_true, assert_false, assert_equal
|
||||
|
||||
COURSE_START_DATE_CSS = "#course-start-date"
|
||||
COURSE_END_DATE_CSS = "#course-end-date"
|
||||
ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date"
|
||||
ENROLLMENT_END_DATE_CSS = "#course-enrollment-end-date"
|
||||
|
||||
COURSE_START_TIME_CSS = "#course-start-time"
|
||||
COURSE_END_TIME_CSS = "#course-end-time"
|
||||
ENROLLMENT_START_TIME_CSS = "#course-enrollment-start-time"
|
||||
ENROLLMENT_END_TIME_CSS = "#course-enrollment-end-time"
|
||||
|
||||
DUMMY_TIME = "3:30pm"
|
||||
DEFAULT_TIME = "12:00am"
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I select Schedule and Details$')
|
||||
def test_i_select_schedule_and_details(step):
|
||||
expand_icon_css = 'li.nav-course-settings i.icon-expand'
|
||||
if world.browser.is_element_present_by_css(expand_icon_css):
|
||||
world.css_click(expand_icon_css)
|
||||
link_css = 'li.nav-course-settings-schedule a'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step('I have set course dates$')
|
||||
def test_i_have_set_course_dates(step):
|
||||
step.given('I have opened a new course in Studio')
|
||||
step.given('I select Schedule and Details')
|
||||
step.given('And I set course dates')
|
||||
|
||||
|
||||
@step('And I set course dates$')
|
||||
def test_and_i_set_course_dates(step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
set_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '12/1/2013')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
|
||||
|
||||
set_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
pause()
|
||||
|
||||
|
||||
@step('Then I see the set dates on refresh$')
|
||||
def test_then_i_see_the_set_dates_on_refresh(step):
|
||||
reload_the_page(step)
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
|
||||
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013')
|
||||
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
|
||||
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
# Unset times get set to 12 AM once the corresponding date has been set.
|
||||
verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('And I clear all the dates except start$')
|
||||
def test_and_i_clear_all_the_dates_except_start(step):
|
||||
set_date_or_time(COURSE_END_DATE_CSS, '')
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '')
|
||||
|
||||
pause()
|
||||
|
||||
|
||||
@step('Then I see cleared dates on refresh$')
|
||||
def test_then_i_see_cleared_dates_on_refresh(step):
|
||||
reload_the_page(step)
|
||||
verify_date_or_time(COURSE_END_DATE_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '')
|
||||
|
||||
verify_date_or_time(COURSE_END_TIME_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_START_TIME_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_END_TIME_CSS, '')
|
||||
|
||||
# Verify course start date (required) and time still there
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('I clear the course start date$')
|
||||
def test_i_clear_the_course_start_date(step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '')
|
||||
|
||||
|
||||
@step('I receive a warning about course start date$')
|
||||
def test_i_receive_a_warning_about_course_start_date(step):
|
||||
assert_true(world.css_has_text('.message-error', 'The course must have an assigned start date.'))
|
||||
assert_true('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class'))
|
||||
assert_true('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
|
||||
|
||||
|
||||
@step('The previously set start date is shown on refresh$')
|
||||
def test_the_previously_set_start_date_is_shown_on_refresh(step):
|
||||
reload_the_page(step)
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('Given I have tried to clear the course start$')
|
||||
def test_i_have_tried_to_clear_the_course_start(step):
|
||||
step.given("I have set course dates")
|
||||
step.given("I clear the course start date")
|
||||
step.given("I receive a warning about course start date")
|
||||
|
||||
|
||||
@step('I have entered a new course start date$')
|
||||
def test_i_have_entered_a_new_course_start_date(step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
|
||||
pause()
|
||||
|
||||
|
||||
@step('The warning about course start date goes away$')
|
||||
def test_the_warning_about_course_start_date_goes_away(step):
|
||||
assert_equal(0, len(world.css_find('.message-error')))
|
||||
assert_false('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class'))
|
||||
assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
|
||||
|
||||
|
||||
@step('My new course start date is shown on refresh$')
|
||||
def test_my_new_course_start_date_is_shown_on_refresh(step):
|
||||
reload_the_page(step)
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
|
||||
# Time should have stayed from before attempt to clear date.
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
############### HELPER METHODS ####################
|
||||
def set_date_or_time(css, date_or_time):
|
||||
"""
|
||||
Sets date or time field.
|
||||
"""
|
||||
world.css_fill(css, date_or_time)
|
||||
e = world.css_find(css).first
|
||||
# hit Enter to apply the changes
|
||||
e._element.send_keys(Keys.ENTER)
|
||||
|
||||
|
||||
def verify_date_or_time(css, date_or_time):
|
||||
"""
|
||||
Verifies date or time field.
|
||||
"""
|
||||
assert_equal(date_or_time, world.css_find(css).first.value)
|
||||
|
||||
|
||||
def pause():
|
||||
"""
|
||||
Must sleep briefly to allow last time save to finish,
|
||||
else refresh of browser will fail.
|
||||
"""
|
||||
time.sleep(float(1))
|
||||
@@ -10,4 +10,4 @@ Feature: Create Course
|
||||
And I fill in the new course information
|
||||
And I press the "Save" button
|
||||
Then the Courseware page has loaded in Studio
|
||||
And I see a link for adding a new section
|
||||
And I see a link for adding a new section
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
|
||||
@@ -6,12 +9,12 @@ from common import *
|
||||
|
||||
@step('There are no courses$')
|
||||
def no_courses(step):
|
||||
clear_courses()
|
||||
world.clear_courses()
|
||||
|
||||
|
||||
@step('I click the New Course button$')
|
||||
def i_click_new_course(step):
|
||||
css_click('.new-course-button')
|
||||
world.css_click('.new-course-button')
|
||||
|
||||
|
||||
@step('I fill in the new course information$')
|
||||
@@ -27,7 +30,7 @@ def i_create_a_course(step):
|
||||
@step('I click the course link in My Courses$')
|
||||
def i_click_the_course_link_in_my_courses(step):
|
||||
course_css = 'span.class-name'
|
||||
css_click(course_css)
|
||||
world.css_click(course_css)
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
@@ -35,28 +38,28 @@ def i_click_the_course_link_in_my_courses(step):
|
||||
@step('the Courseware page has loaded in Studio$')
|
||||
def courseware_page_has_loaded_in_studio(step):
|
||||
course_title_css = 'span.course-title'
|
||||
assert world.browser.is_element_present_by_css(course_title_css)
|
||||
assert world.is_css_present(course_title_css)
|
||||
|
||||
|
||||
@step('I see the course listed in My Courses$')
|
||||
def i_see_the_course_in_my_courses(step):
|
||||
course_css = 'span.class-name'
|
||||
assert_css_with_text(course_css, 'Robot Super Course')
|
||||
assert world.css_has_text(course_css, 'Robot Super Course')
|
||||
|
||||
|
||||
@step('the course is loaded$')
|
||||
def course_is_loaded(step):
|
||||
class_css = 'a.class-name'
|
||||
assert_css_with_text(class_css, 'Robot Super Course')
|
||||
assert world.css_has_text(course_css, 'Robot Super Cousre')
|
||||
|
||||
|
||||
@step('I am on the "([^"]*)" tab$')
|
||||
def i_am_on_tab(step, tab_name):
|
||||
header_css = 'div.inner-wrapper h1'
|
||||
assert_css_with_text(header_css, tab_name)
|
||||
assert world.css_has_text(header_css, tab_name)
|
||||
|
||||
|
||||
@step('I see a link for adding a new section$')
|
||||
def i_see_new_section_link(step):
|
||||
link_css = 'a.new-courseware-section-button'
|
||||
assert_css_with_text(link_css, '+ New Section')
|
||||
assert world.css_has_text(link_css, '+ New Section')
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_equal
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
import time
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
@@ -10,7 +11,7 @@ import time
|
||||
@step('I click the new section link$')
|
||||
def i_click_new_section_link(step):
|
||||
link_css = 'a.new-courseware-section-button'
|
||||
css_click(link_css)
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step('I enter the section name and click save$')
|
||||
@@ -31,21 +32,13 @@ def i_have_added_new_section(step):
|
||||
@step('I click the Edit link for the release date$')
|
||||
def i_click_the_edit_link_for_the_release_date(step):
|
||||
button_css = 'div.section-published-date a.edit-button'
|
||||
css_click(button_css)
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step('I save a new section release date$')
|
||||
def i_save_a_new_section_release_date(step):
|
||||
date_css = 'input.start-date.date.hasDatepicker'
|
||||
time_css = 'input.start-time.time.ui-timepicker-input'
|
||||
css_fill(date_css, '12/25/2013')
|
||||
# hit TAB to get to the time field
|
||||
e = css_find(date_css).first
|
||||
e._element.send_keys(Keys.TAB)
|
||||
css_fill(time_css, '12:00am')
|
||||
e = css_find(time_css).first
|
||||
e._element.send_keys(Keys.TAB)
|
||||
time.sleep(float(1))
|
||||
set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013',
|
||||
'input.start-time.time.ui-timepicker-input', '12:00am')
|
||||
world.browser.click_link_by_text('Save')
|
||||
|
||||
|
||||
@@ -64,13 +57,13 @@ def i_see_my_section_name_with_quote_on_the_courseware_page(step):
|
||||
|
||||
@step('I click to edit the section name$')
|
||||
def i_click_to_edit_section_name(step):
|
||||
css_click('span.section-name-span')
|
||||
world.css_click('span.section-name-span')
|
||||
|
||||
|
||||
@step('I see the complete section name with a quote in the editor$')
|
||||
def i_see_complete_section_name_with_quote_in_editor(step):
|
||||
css = '.edit-section-name'
|
||||
assert world.browser.is_element_present_by_css(css, 5)
|
||||
assert world.is_css_present(css)
|
||||
assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
|
||||
|
||||
|
||||
@@ -85,7 +78,7 @@ def i_see_a_release_date_for_my_section(step):
|
||||
import re
|
||||
|
||||
css = 'span.published-status'
|
||||
assert world.browser.is_element_present_by_css(css)
|
||||
assert world.is_css_present(css)
|
||||
status_text = world.browser.find_by_css(css).text
|
||||
|
||||
# e.g. 11/06/2012 at 16:25
|
||||
@@ -99,20 +92,20 @@ def i_see_a_release_date_for_my_section(step):
|
||||
@step('I see a link to create a new subsection$')
|
||||
def i_see_a_link_to_create_a_new_subsection(step):
|
||||
css = 'a.new-subsection-item'
|
||||
assert world.browser.is_element_present_by_css(css)
|
||||
assert world.is_css_present(css)
|
||||
|
||||
|
||||
@step('the section release date picker is not visible$')
|
||||
def the_section_release_date_picker_not_visible(step):
|
||||
css = 'div.edit-subsection-publish-settings'
|
||||
assert False, world.browser.find_by_css(css).visible
|
||||
assert not world.css_visible(css)
|
||||
|
||||
|
||||
@step('the section release date is updated$')
|
||||
def the_section_release_date_is_updated(step):
|
||||
css = 'span.published-status'
|
||||
status_text = world.browser.find_by_css(css).text
|
||||
assert_equal(status_text,'Will Release: 12/25/2013 at 12:00am')
|
||||
status_text = world.css_text(css)
|
||||
assert_equal(status_text, 'Will Release: 12/25/2013 at 12:00am')
|
||||
|
||||
|
||||
############ HELPER METHODS ###################
|
||||
@@ -120,10 +113,10 @@ def the_section_release_date_is_updated(step):
|
||||
def save_section_name(name):
|
||||
name_css = '.new-section-name'
|
||||
save_css = '.new-section-name-save'
|
||||
css_fill(name_css, name)
|
||||
css_click(save_css)
|
||||
world.css_fill(name_css, name)
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
def see_my_section_on_the_courseware_page(name):
|
||||
section_css = 'span.section-name-span'
|
||||
assert_css_with_text(section_css, name)
|
||||
assert world.css_has_text(section_css, name)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
|
||||
@@ -17,9 +20,10 @@ def i_press_the_button_on_the_registration_form(step):
|
||||
submit_css = 'form#register_form button#submit'
|
||||
# Workaround for click not working on ubuntu
|
||||
# for some unknown reason.
|
||||
e = css_find(submit_css)
|
||||
e = world.css_find(submit_css)
|
||||
e.type(' ')
|
||||
|
||||
|
||||
@step('I should see be on the studio home page$')
|
||||
def i_should_see_be_on_the_studio_home_page(step):
|
||||
assert world.browser.find_by_css('div.inner-wrapper')
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
Feature: Overview Toggle Section
|
||||
In order to quickly view the details of a course's section or to scan the inventory of sections
|
||||
As a course author
|
||||
I want to toggle the visibility of each section's subsection details in the overview listing
|
||||
As a course author
|
||||
I want to toggle the visibility of each section's subsection details in the overview listing
|
||||
|
||||
Scenario: The default layout for the overview page is to show sections in expanded view
|
||||
Given I have a course with multiple sections
|
||||
When I navigate to the course overview page
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
When I navigate to the course overview page
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
Scenario: Expand/collapse for a course with no sections
|
||||
Scenario: Expand /collapse for a course with no sections
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
Then I do not see the "Collapse All Sections" link
|
||||
When I navigate to the course overview page
|
||||
Then I do not see the "Collapse All Sections" link
|
||||
|
||||
Scenario: Collapse link appears after creating first section of a course
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
And I add a section
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
When I navigate to the course overview page
|
||||
And I add a section
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
@skip-phantom
|
||||
Scenario: Collapse link is not removed after last section of a course is deleted
|
||||
Given I have a course with 1 section
|
||||
And I navigate to the course overview page
|
||||
And I navigate to the course overview page
|
||||
When I press the "section" delete icon
|
||||
And I confirm the alert
|
||||
Then I see the "Collapse All Sections" link
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_true, assert_false, assert_equal
|
||||
@@ -8,13 +11,13 @@ logger = getLogger(__name__)
|
||||
|
||||
@step(u'I have a course with no sections$')
|
||||
def have_a_course(step):
|
||||
clear_courses()
|
||||
world.clear_courses()
|
||||
course = world.CourseFactory.create()
|
||||
|
||||
|
||||
@step(u'I have a course with 1 section$')
|
||||
def have_a_course_with_1_section(step):
|
||||
clear_courses()
|
||||
world.clear_courses()
|
||||
course = world.CourseFactory.create()
|
||||
section = world.ItemFactory.create(parent_location=course.location)
|
||||
subsection1 = world.ItemFactory.create(
|
||||
@@ -25,7 +28,7 @@ def have_a_course_with_1_section(step):
|
||||
|
||||
@step(u'I have a course with multiple sections$')
|
||||
def have_a_course_with_two_sections(step):
|
||||
clear_courses()
|
||||
world.clear_courses()
|
||||
course = world.CourseFactory.create()
|
||||
section = world.ItemFactory.create(parent_location=course.location)
|
||||
subsection1 = world.ItemFactory.create(
|
||||
@@ -49,7 +52,7 @@ def have_a_course_with_two_sections(step):
|
||||
def navigate_to_the_course_overview_page(step):
|
||||
log_into_studio(is_staff=True)
|
||||
course_locator = '.class-name'
|
||||
css_click(course_locator)
|
||||
world.css_click(course_locator)
|
||||
|
||||
|
||||
@step(u'I navigate to the courseware page of a course with multiple sections')
|
||||
@@ -66,44 +69,44 @@ def i_add_a_section(step):
|
||||
@step(u'I click the "([^"]*)" link$')
|
||||
def i_click_the_text_span(step, text):
|
||||
span_locator = '.toggle-button-sections span'
|
||||
assert_true(world.browser.is_element_present_by_css(span_locator, 5))
|
||||
assert_true(world.browser.is_element_present_by_css(span_locator))
|
||||
# first make sure that the expand/collapse text is the one you expected
|
||||
assert_equal(world.browser.find_by_css(span_locator).value, text)
|
||||
css_click(span_locator)
|
||||
world.css_click(span_locator)
|
||||
|
||||
|
||||
@step(u'I collapse the first section$')
|
||||
def i_collapse_a_section(step):
|
||||
collapse_locator = 'section.courseware-section a.collapse'
|
||||
css_click(collapse_locator)
|
||||
world.css_click(collapse_locator)
|
||||
|
||||
|
||||
@step(u'I expand the first section$')
|
||||
def i_expand_a_section(step):
|
||||
expand_locator = 'section.courseware-section a.expand'
|
||||
css_click(expand_locator)
|
||||
world.css_click(expand_locator)
|
||||
|
||||
|
||||
@step(u'I see the "([^"]*)" link$')
|
||||
def i_see_the_span_with_text(step, text):
|
||||
span_locator = '.toggle-button-sections span'
|
||||
assert_true(world.browser.is_element_present_by_css(span_locator, 5))
|
||||
assert_equal(world.browser.find_by_css(span_locator).value, text)
|
||||
assert_true(world.browser.find_by_css(span_locator).visible)
|
||||
assert_true(world.is_css_present(span_locator))
|
||||
assert_equal(world.css_find(span_locator).value, text)
|
||||
assert_true(world.css_visible(span_locator))
|
||||
|
||||
|
||||
@step(u'I do not see the "([^"]*)" link$')
|
||||
def i_do_not_see_the_span_with_text(step, text):
|
||||
# Note that the span will exist on the page but not be visible
|
||||
span_locator = '.toggle-button-sections span'
|
||||
assert_true(world.browser.is_element_present_by_css(span_locator))
|
||||
assert_false(world.browser.find_by_css(span_locator).visible)
|
||||
assert_true(world.is_css_present(span_locator))
|
||||
assert_false(world.css_visible(span_locator))
|
||||
|
||||
|
||||
@step(u'all sections are expanded$')
|
||||
def all_sections_are_expanded(step):
|
||||
subsection_locator = 'div.subsection-list'
|
||||
subsections = world.browser.find_by_css(subsection_locator)
|
||||
subsections = world.css_find(subsection_locator)
|
||||
for s in subsections:
|
||||
assert_true(s.visible)
|
||||
|
||||
@@ -111,6 +114,6 @@ def all_sections_are_expanded(step):
|
||||
@step(u'all sections are collapsed$')
|
||||
def all_sections_are_expanded(step):
|
||||
subsection_locator = 'div.subsection-list'
|
||||
subsections = world.browser.find_by_css(subsection_locator)
|
||||
subsections = world.css_find(subsection_locator)
|
||||
for s in subsections:
|
||||
assert_false(s.visible)
|
||||
|
||||
@@ -17,6 +17,21 @@ Feature: Create Subsection
|
||||
And I click to edit the subsection name
|
||||
Then I see the complete subsection name with a quote in the editor
|
||||
|
||||
Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258)
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
And I mark it as Homework
|
||||
Then I see it marked as Homework
|
||||
And I reload the page
|
||||
Then I see it marked as Homework
|
||||
|
||||
Scenario: Set a due date in a different year (bug #256)
|
||||
Given I have opened a new subsection in Studio
|
||||
And I have set a release date and due date in different years
|
||||
Then I see the correct dates
|
||||
And I reload the page
|
||||
Then I see the correct dates
|
||||
|
||||
@skip-phantom
|
||||
Scenario: Delete a subsection
|
||||
Given I have opened a new course section in Studio
|
||||
@@ -25,3 +40,5 @@ Feature: Create Subsection
|
||||
When I press the "subsection" delete icon
|
||||
And I confirm the alert
|
||||
Then the subsection does not exist
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_equal
|
||||
@@ -7,16 +10,27 @@ from nose.tools import assert_equal
|
||||
|
||||
@step('I have opened a new course section in Studio$')
|
||||
def i_have_opened_a_new_course_section(step):
|
||||
clear_courses()
|
||||
world.clear_courses()
|
||||
log_into_studio()
|
||||
create_a_course()
|
||||
add_section()
|
||||
|
||||
|
||||
@step('I have added a new subsection$')
|
||||
def i_have_added_a_new_subsection(step):
|
||||
add_subsection()
|
||||
|
||||
|
||||
@step('I have opened a new subsection in Studio$')
|
||||
def i_have_opened_a_new_subsection(step):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
step.given('I have added a new subsection')
|
||||
world.css_click('span.subsection-name-value')
|
||||
|
||||
|
||||
@step('I click the New Subsection link')
|
||||
def i_click_the_new_subsection_link(step):
|
||||
css = 'a.new-subsection-item'
|
||||
css_click(css)
|
||||
world.css_click('a.new-subsection-item')
|
||||
|
||||
|
||||
@step('I enter the subsection name and click save$')
|
||||
@@ -31,19 +45,41 @@ def i_save_subsection_name_with_quote(step):
|
||||
|
||||
@step('I click to edit the subsection name$')
|
||||
def i_click_to_edit_subsection_name(step):
|
||||
css_click('span.subsection-name-value')
|
||||
world.css_click('span.subsection-name-value')
|
||||
|
||||
|
||||
@step('I see the complete subsection name with a quote in the editor$')
|
||||
def i_see_complete_subsection_name_with_quote_in_editor(step):
|
||||
css = '.subsection-display-name-input'
|
||||
assert world.browser.is_element_present_by_css(css, 5)
|
||||
assert_equal(world.browser.find_by_css(css).value, 'Subsection With "Quote"')
|
||||
assert world.is_css_present(css)
|
||||
assert_equal(world.css_find(css).value, 'Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('I have added a new subsection$')
|
||||
def i_have_added_a_new_subsection(step):
|
||||
add_subsection()
|
||||
@step('I have set a release date and due date in different years$')
|
||||
def test_have_set_dates_in_different_years(step):
|
||||
set_date_and_time('input#start_date', '12/25/2011', 'input#start_time', '3:00am')
|
||||
world.css_click('.set-date')
|
||||
# Use a year in the past so that current year will always be different.
|
||||
set_date_and_time('input#due_date', '01/02/2012', 'input#due_time', '4:00am')
|
||||
|
||||
|
||||
@step('I see the correct dates$')
|
||||
def i_see_the_correct_dates(step):
|
||||
assert_equal('12/25/2011', world.css_find('input#start_date').first.value)
|
||||
assert_equal('3:00am', world.css_find('input#start_time').first.value)
|
||||
assert_equal('01/02/2012', world.css_find('input#due_date').first.value)
|
||||
assert_equal('4:00am', world.css_find('input#due_time').first.value)
|
||||
|
||||
|
||||
@step('I mark it as Homework$')
|
||||
def i_mark_it_as_homework(step):
|
||||
world.css_click('a.menu-toggle')
|
||||
world.browser.click_link_by_text('Homework')
|
||||
|
||||
|
||||
@step('I see it marked as Homework$')
|
||||
def i_see_it_marked__as_homework(step):
|
||||
assert_equal(world.css_find(".status-label").value, 'Homework')
|
||||
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
@@ -70,11 +106,12 @@ def the_subsection_does_not_exist(step):
|
||||
def save_subsection_name(name):
|
||||
name_css = 'input.new-subsection-name-input'
|
||||
save_css = 'input.new-subsection-name-save'
|
||||
css_fill(name_css, name)
|
||||
css_click(save_css)
|
||||
world.css_fill(name_css, name)
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
def see_subsection_name(name):
|
||||
css = 'span.subsection-name'
|
||||
assert world.browser.is_element_present_by_css(css)
|
||||
assert world.is_css_present(css)
|
||||
css = 'span.subsection-name-value'
|
||||
assert_css_with_text(css, name)
|
||||
assert world.css_has_text(css, name)
|
||||
|
||||
96
cms/djangoapps/contentstore/tests/test_checklists.py
Normal file
96
cms/djangoapps/contentstore/tests/test_checklists.py
Normal file
@@ -0,0 +1,96 @@
|
||||
""" Unit tests for checklist methods in views.py. """
|
||||
from contentstore.utils import get_modulestore, get_url_reverse
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
import json
|
||||
|
||||
|
||||
class ChecklistTestCase(CourseTestCase):
|
||||
""" Test for checklist get and put methods. """
|
||||
def setUp(self):
|
||||
""" Creates the test course. """
|
||||
super(ChecklistTestCase, self).setUp()
|
||||
self.course = CourseFactory.create(org='mitX', number='333', display_name='Checklists Course')
|
||||
|
||||
def get_persisted_checklists(self):
|
||||
""" Returns the checklists as persisted in the modulestore. """
|
||||
modulestore = get_modulestore(self.course.location)
|
||||
return modulestore.get_item(self.course.location).checklists
|
||||
|
||||
def test_get_checklists(self):
|
||||
""" Tests the get checklists method. """
|
||||
checklists_url = get_url_reverse('Checklists', self.course)
|
||||
response = self.client.get(checklists_url)
|
||||
self.assertContains(response, "Getting Started With Studio")
|
||||
payload = response.content
|
||||
|
||||
# Now delete the checklists from the course and verify they get repopulated (for courses
|
||||
# created before checklists were introduced).
|
||||
self.course.checklists = None
|
||||
modulestore = get_modulestore(self.course.location)
|
||||
modulestore.update_metadata(self.course.location, own_metadata(self.course))
|
||||
self.assertEquals(self.get_persisted_checklists(), None)
|
||||
response = self.client.get(checklists_url)
|
||||
self.assertEquals(payload, response.content)
|
||||
|
||||
def test_update_checklists_no_index(self):
|
||||
""" No checklist index, should return all of them. """
|
||||
update_url = reverse('checklists_updates', kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name})
|
||||
|
||||
returned_checklists = json.loads(self.client.get(update_url).content)
|
||||
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
|
||||
|
||||
def test_update_checklists_index_ignored_on_get(self):
|
||||
""" Checklist index ignored on get. """
|
||||
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 1})
|
||||
|
||||
returned_checklists = json.loads(self.client.get(update_url).content)
|
||||
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
|
||||
|
||||
def test_update_checklists_post_no_index(self):
|
||||
""" No checklist index, will error on post. """
|
||||
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name})
|
||||
response = self.client.post(update_url)
|
||||
self.assertContains(response, 'Could not save checklist', status_code=400)
|
||||
|
||||
def test_update_checklists_index_out_of_range(self):
|
||||
""" Checklist index out of range, will error on post. """
|
||||
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 100})
|
||||
response = self.client.post(update_url)
|
||||
self.assertContains(response, 'Could not save checklist', status_code=400)
|
||||
|
||||
def test_update_checklists_index(self):
|
||||
""" Check that an update of a particular checklist works. """
|
||||
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 2})
|
||||
payload = self.course.checklists[2]
|
||||
self.assertFalse(payload.get('is_checked'))
|
||||
payload['is_checked'] = True
|
||||
|
||||
returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content)
|
||||
self.assertTrue(returned_checklist.get('is_checked'))
|
||||
self.assertEqual(self.get_persisted_checklists()[2], returned_checklist)
|
||||
|
||||
def test_update_checklists_delete_unsupported(self):
|
||||
""" Delete operation is not supported. """
|
||||
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 100})
|
||||
response = self.client.delete(update_url)
|
||||
self.assertContains(response, 'Unsupported request', status_code=400)
|
||||
@@ -25,7 +25,7 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.templates import update_templates
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
@@ -37,6 +37,14 @@ TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE)
|
||||
TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
|
||||
class MongoCollectionFindWrapper(object):
|
||||
def __init__(self, original):
|
||||
self.original = original
|
||||
self.counter = 0
|
||||
|
||||
def find(self, query, *args, **kwargs):
|
||||
self.counter = self.counter+1
|
||||
return self.original(query, *args, **kwargs)
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
|
||||
class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
@@ -77,6 +85,43 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
def test_edit_unit_full(self):
|
||||
self.check_edit_unit('full')
|
||||
|
||||
def _get_draft_counts(self, item):
|
||||
cnt = 1 if getattr(item, 'is_draft', False) else 0
|
||||
for child in item.get_children():
|
||||
cnt = cnt + self._get_draft_counts(child)
|
||||
|
||||
return cnt
|
||||
|
||||
def test_get_depth_with_drafts(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['simple'])
|
||||
|
||||
course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
|
||||
'course', '2012_Fall', None]), depth=None)
|
||||
|
||||
# make sure no draft items have been returned
|
||||
num_drafts = self._get_draft_counts(course)
|
||||
self.assertEqual(num_drafts, 0)
|
||||
|
||||
problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
|
||||
'problem', 'ps01-simple', None]))
|
||||
|
||||
# put into draft
|
||||
modulestore('draft').clone_item(problem.location, problem.location)
|
||||
|
||||
# make sure we can query that item and verify that it is a draft
|
||||
draft_problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
|
||||
'problem', 'ps01-simple', None]))
|
||||
self.assertTrue(getattr(draft_problem,'is_draft', False))
|
||||
|
||||
#now requery with depth
|
||||
course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
|
||||
'course', '2012_Fall', None]), depth=None)
|
||||
|
||||
# make sure just one draft item have been returned
|
||||
num_drafts = self._get_draft_counts(course)
|
||||
self.assertEqual(num_drafts, 1)
|
||||
|
||||
|
||||
def test_static_tab_reordering(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
@@ -115,6 +160,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# check that there's actually content in the 'question' field
|
||||
self.assertGreater(len(items[0].question),0)
|
||||
|
||||
def test_xlint_fails(self):
|
||||
err_cnt = perform_xlint('common/test/data', ['full'])
|
||||
self.assertGreater(err_cnt, 0)
|
||||
|
||||
def test_delete(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
@@ -145,8 +194,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# make sure the parent no longer points to the child object which was deleted
|
||||
self.assertFalse(sequential.location.url() in chapter.children)
|
||||
|
||||
|
||||
|
||||
def test_about_overrides(self):
|
||||
'''
|
||||
This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html
|
||||
@@ -207,6 +254,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_bad_contentstore_request(self):
|
||||
resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_delete_course(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
@@ -307,6 +358,28 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# 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')
|
||||
|
||||
def test_prefetch_children(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
module_store = modulestore('direct')
|
||||
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
|
||||
wrapper = MongoCollectionFindWrapper(module_store.collection.find)
|
||||
module_store.collection.find = wrapper.find
|
||||
course = module_store.get_item(location, depth=2)
|
||||
|
||||
# make sure we haven't done too many round trips to DB
|
||||
# note we say 4 round trips here for 1) the course, 2 & 3) for the chapters and sequentials, and
|
||||
# 4) because of the RT due to calculating the inherited metadata
|
||||
self.assertEqual(wrapper.counter, 4)
|
||||
|
||||
# make sure we pre-fetched a known sequential which should be at depth=2
|
||||
self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential',
|
||||
'Administrivia_and_Circuit_Elements', None]) in course.system.module_data)
|
||||
|
||||
# make sure we don't have a specific vertical which should be at depth=3
|
||||
self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58',
|
||||
None]) in course.system.module_data)
|
||||
|
||||
def test_export_course_with_unknown_metadata(self):
|
||||
module_store = modulestore('direct')
|
||||
content_store = contentstore()
|
||||
@@ -528,7 +601,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
module_store.update_children(parent.location, parent.children + [new_component_location.url()])
|
||||
|
||||
# flush the cache
|
||||
module_store.get_cached_metadata_inheritance_tree(new_component_location, -1)
|
||||
module_store.refresh_cached_metadata_inheritance_tree(new_component_location)
|
||||
new_module = module_store.get_item(new_component_location)
|
||||
|
||||
# check for grace period definition which should be defined at the course level
|
||||
@@ -543,7 +616,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
module_store.update_metadata(new_module.location, own_metadata(new_module))
|
||||
|
||||
# flush the cache and refetch
|
||||
module_store.get_cached_metadata_inheritance_tree(new_component_location, -1)
|
||||
module_store.refresh_cached_metadata_inheritance_tree(new_component_location)
|
||||
new_module = module_store.get_item(new_component_location)
|
||||
|
||||
self.assertEqual(timedelta(1), new_module.lms.graceperiod)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import datetime
|
||||
import json
|
||||
import copy
|
||||
from util import converters
|
||||
from util.converters import jsdate_to_time
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.client import Client
|
||||
@@ -15,33 +13,13 @@ from models.settings.course_details import (CourseDetails,
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from contentstore.utils import get_modulestore
|
||||
|
||||
from django.test import TestCase
|
||||
from .utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
# YYYY-MM-DDThh:mm:ss.s+/-HH:MM
|
||||
class ConvertersTestCase(TestCase):
|
||||
@staticmethod
|
||||
def struct_to_datetime(struct_time):
|
||||
return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, struct_time.tm_mday, struct_time.tm_hour,
|
||||
struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC())
|
||||
|
||||
def compare_dates(self, date1, date2, expected_delta):
|
||||
dt1 = ConvertersTestCase.struct_to_datetime(date1)
|
||||
dt2 = ConvertersTestCase.struct_to_datetime(date2)
|
||||
self.assertEqual(dt1 - dt2, expected_delta, str(date1) + "-" + str(date2) + "!=" + str(expected_delta))
|
||||
|
||||
def test_iso_to_struct(self):
|
||||
self.compare_dates(converters.jsdate_to_time("2013-01-01"), converters.jsdate_to_time("2012-12-31"), datetime.timedelta(days=1))
|
||||
self.compare_dates(converters.jsdate_to_time("2013-01-01T00"), converters.jsdate_to_time("2012-12-31T23"), datetime.timedelta(hours=1))
|
||||
self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00"), converters.jsdate_to_time("2012-12-31T23:59"), datetime.timedelta(minutes=1))
|
||||
self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00:00"), converters.jsdate_to_time("2012-12-31T23:59:59"), datetime.timedelta(seconds=1))
|
||||
|
||||
from xmodule.fields import Date
|
||||
|
||||
class CourseTestCase(ModuleStoreTestCase):
|
||||
def setUp(self):
|
||||
@@ -104,7 +82,7 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
## NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
|
||||
# # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
|
||||
jsondetails = CourseDetails.fetch(self.course_location)
|
||||
jsondetails.syllabus = "<a href='foo'>bar</a>"
|
||||
# encode - decode to convert date fields and other data which changes form
|
||||
@@ -170,19 +148,26 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
|
||||
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
|
||||
|
||||
@staticmethod
|
||||
def struct_to_datetime(struct_time):
|
||||
return datetime.datetime(struct_time.tm_year, struct_time.tm_mon,
|
||||
struct_time.tm_mday, struct_time.tm_hour,
|
||||
struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC())
|
||||
|
||||
def compare_date_fields(self, details, encoded, context, field):
|
||||
if details[field] is not None:
|
||||
date = Date()
|
||||
if field in encoded and encoded[field] is not None:
|
||||
encoded_encoded = jsdate_to_time(encoded[field])
|
||||
dt1 = ConvertersTestCase.struct_to_datetime(encoded_encoded)
|
||||
encoded_encoded = date.from_json(encoded[field])
|
||||
dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded)
|
||||
|
||||
if isinstance(details[field], datetime.datetime):
|
||||
dt2 = details[field]
|
||||
else:
|
||||
details_encoded = jsdate_to_time(details[field])
|
||||
dt2 = ConvertersTestCase.struct_to_datetime(details_encoded)
|
||||
details_encoded = date.from_json(details[field])
|
||||
dt2 = CourseDetailsViewTest.struct_to_datetime(details_encoded)
|
||||
|
||||
expected_delta = datetime.timedelta(0)
|
||||
expected_delta = datetime.timedelta(0)
|
||||
self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context)
|
||||
else:
|
||||
self.fail(field + " missing from encoded but in details at " + context)
|
||||
@@ -269,7 +254,7 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
CourseTestCase.setUp(self)
|
||||
# add in the full class too
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
self.fullcourse_location = Location(['i4x','edX','full','course','6.002_Spring_2012', None])
|
||||
self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])
|
||||
|
||||
|
||||
def test_fetch_initial_fields(self):
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'''unit tests for course_info views and models.'''
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
import json
|
||||
from webob.exc import HTTPServerError
|
||||
from django.http import HttpResponseBadRequest
|
||||
|
||||
|
||||
class CourseUpdateTest(CourseTestCase):
|
||||
'''The do all and end all of unit test cases.'''
|
||||
def test_course_update(self):
|
||||
'''Go through each interface and ensure it works.'''
|
||||
# first get the update to force the creation
|
||||
url = reverse('course_info',
|
||||
kwargs={'org': self.course_location.org,
|
||||
@@ -18,9 +19,10 @@ class CourseUpdateTest(CourseTestCase):
|
||||
content = init_content + '</iframe>'
|
||||
payload = {'content': content,
|
||||
'date': 'January 8, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
|
||||
@@ -90,14 +92,22 @@ class CourseUpdateTest(CourseTestCase):
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
|
||||
payload = json.loads(resp.content)
|
||||
|
||||
self.assertContains(
|
||||
self.client.post(url, json.dumps(payload), "application/json"),
|
||||
'<garbage')
|
||||
|
||||
# set to valid html which would break an xml parser
|
||||
content = "<p><br><br></p>"
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
payload = json.loads(resp.content)
|
||||
self.assertHTMLEqual(content, json.loads(resp.content)['content'])
|
||||
|
||||
# now try to delete a non-existent update
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
|
||||
@@ -1,19 +1,72 @@
|
||||
from contentstore import utils
|
||||
""" Tests for utils. """
|
||||
from contentstore import utils
|
||||
import mock
|
||||
from django.test import TestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from .utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
class LMSLinksTestCase(TestCase):
|
||||
""" Tests for LMS links. """
|
||||
def about_page_test(self):
|
||||
""" Get URL for about page. """
|
||||
location = 'i4x', 'mitX', '101', 'course', 'test'
|
||||
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
|
||||
link = utils.get_lms_link_for_about_page(location)
|
||||
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about")
|
||||
|
||||
def ls_link_test(self):
|
||||
def lms_link_test(self):
|
||||
""" Tests get_lms_link_for_item. """
|
||||
location = 'i4x', 'mitX', '101', 'vertical', 'contacting_us'
|
||||
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
|
||||
link = utils.get_lms_link_for_item(location, False)
|
||||
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
|
||||
link = utils.get_lms_link_for_item(location, True)
|
||||
self.assertEquals(link, "//preview.localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
|
||||
self.assertEquals(
|
||||
link,
|
||||
"//preview.localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us"
|
||||
)
|
||||
|
||||
|
||||
class UrlReverseTestCase(ModuleStoreTestCase):
|
||||
""" Tests for get_url_reverse """
|
||||
def test_CoursePageNames(self):
|
||||
""" Test the defined course pages. """
|
||||
course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course')
|
||||
|
||||
self.assertEquals(
|
||||
'/manage_users/i4x://mitX/666/course/URL_Reverse_Course',
|
||||
utils.get_url_reverse('ManageUsers', course)
|
||||
)
|
||||
|
||||
self.assertEquals(
|
||||
'/mitX/666/settings-details/URL_Reverse_Course',
|
||||
utils.get_url_reverse('SettingsDetails', course)
|
||||
)
|
||||
|
||||
self.assertEquals(
|
||||
'/mitX/666/settings-grading/URL_Reverse_Course',
|
||||
utils.get_url_reverse('SettingsGrading', course)
|
||||
)
|
||||
|
||||
self.assertEquals(
|
||||
'/mitX/666/course/URL_Reverse_Course',
|
||||
utils.get_url_reverse('CourseOutline', course)
|
||||
)
|
||||
|
||||
self.assertEquals(
|
||||
'/mitX/666/checklists/URL_Reverse_Course',
|
||||
utils.get_url_reverse('Checklists', course)
|
||||
)
|
||||
|
||||
def test_unknown_passes_through(self):
|
||||
""" Test that unknown values pass through. """
|
||||
course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course')
|
||||
self.assertEquals(
|
||||
'foobar',
|
||||
utils.get_url_reverse('foobar', course)
|
||||
)
|
||||
self.assertEquals(
|
||||
'https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about',
|
||||
utils.get_url_reverse('https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', course)
|
||||
)
|
||||
@@ -1,3 +1,9 @@
|
||||
'''
|
||||
Utilities for contentstore tests
|
||||
'''
|
||||
|
||||
#pylint: disable=W0603
|
||||
|
||||
import json
|
||||
import copy
|
||||
from uuid import uuid4
|
||||
@@ -17,36 +23,89 @@ class ModuleStoreTestCase(TestCase):
|
||||
collection with templates before running the TestCase
|
||||
and drops it they are finished. """
|
||||
|
||||
def _pre_setup(self):
|
||||
super(ModuleStoreTestCase, self)._pre_setup()
|
||||
@staticmethod
|
||||
def flush_mongo_except_templates():
|
||||
'''
|
||||
Delete everything in the module store except templates
|
||||
'''
|
||||
modulestore = xmodule.modulestore.django.modulestore()
|
||||
|
||||
# This query means: every item in the collection
|
||||
# that is not a template
|
||||
query = {"_id.course": {"$ne": "templates"}}
|
||||
|
||||
# Remove everything except templates
|
||||
modulestore.collection.remove(query)
|
||||
|
||||
@staticmethod
|
||||
def load_templates_if_necessary():
|
||||
'''
|
||||
Load templates into the modulestore only if they do not already exist.
|
||||
We need the templates, because they are copied to create
|
||||
XModules such as sections and problems
|
||||
'''
|
||||
modulestore = xmodule.modulestore.django.modulestore()
|
||||
|
||||
# Count the number of templates
|
||||
query = {"_id.course": "templates"}
|
||||
num_templates = modulestore.collection.find(query).count()
|
||||
|
||||
if num_templates < 1:
|
||||
update_templates()
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
'''
|
||||
Flush the mongo store and set up templates
|
||||
'''
|
||||
|
||||
# Use a uuid to differentiate
|
||||
# the mongo collections on jenkins.
|
||||
self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE)
|
||||
self.test_MODULESTORE = self.orig_MODULESTORE
|
||||
self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
|
||||
self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
|
||||
settings.MODULESTORE = self.test_MODULESTORE
|
||||
|
||||
# Flush and initialize the module store
|
||||
# It needs the templates because it creates new records
|
||||
# by cloning from the template.
|
||||
# Note that if your test module gets in some weird state
|
||||
# (though it shouldn't), do this manually
|
||||
# from the bash shell to drop it:
|
||||
# $ mongo test_xmodule --eval "db.dropDatabase()"
|
||||
cls.orig_modulestore = copy.deepcopy(settings.MODULESTORE)
|
||||
test_modulestore = cls.orig_modulestore
|
||||
test_modulestore['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
|
||||
test_modulestore['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
|
||||
xmodule.modulestore.django._MODULESTORES = {}
|
||||
update_templates()
|
||||
|
||||
settings.MODULESTORE = test_modulestore
|
||||
|
||||
TestCase.setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
'''
|
||||
Revert to the old modulestore settings
|
||||
'''
|
||||
|
||||
# Clean up by dropping the collection
|
||||
modulestore = xmodule.modulestore.django.modulestore()
|
||||
modulestore.collection.drop()
|
||||
|
||||
# Restore the original modulestore settings
|
||||
settings.MODULESTORE = cls.orig_modulestore
|
||||
|
||||
def _pre_setup(self):
|
||||
'''
|
||||
Remove everything but the templates before each test
|
||||
'''
|
||||
|
||||
# Flush anything that is not a template
|
||||
ModuleStoreTestCase.flush_mongo_except_templates()
|
||||
|
||||
# Check that we have templates loaded; if not, load them
|
||||
ModuleStoreTestCase.load_templates_if_necessary()
|
||||
|
||||
# Call superclass implementation
|
||||
super(ModuleStoreTestCase, self)._pre_setup()
|
||||
|
||||
def _post_teardown(self):
|
||||
# Make sure you flush out the modulestore.
|
||||
# Drop the collection at the end of the test,
|
||||
# otherwise there will be lingering collections leftover
|
||||
# from executing the tests.
|
||||
xmodule.modulestore.django._MODULESTORES = {}
|
||||
xmodule.modulestore.django.modulestore().collection.drop()
|
||||
settings.MODULESTORE = self.orig_MODULESTORE
|
||||
'''
|
||||
Flush everything we created except the templates
|
||||
'''
|
||||
# Flush anything that is not a template
|
||||
ModuleStoreTestCase.flush_mongo_except_templates()
|
||||
|
||||
# Call superclass implementation
|
||||
super(ModuleStoreTestCase, self)._post_teardown()
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from django.core.urlresolvers import reverse
|
||||
import copy
|
||||
|
||||
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
|
||||
|
||||
#In order to instantiate an open ended tab automatically, need to have this data
|
||||
OPEN_ENDED_PANEL = {"name" : "Open Ended Panel", "type" : "open_ended"}
|
||||
|
||||
def get_modulestore(location):
|
||||
"""
|
||||
@@ -136,7 +141,7 @@ def compute_unit_state(unit):
|
||||
'private' content is editabled and not visible in the LMS
|
||||
"""
|
||||
|
||||
if unit.cms.is_draft:
|
||||
if getattr(unit, 'is_draft', False):
|
||||
try:
|
||||
modulestore('direct').get_item(unit.location)
|
||||
return UnitState.draft
|
||||
@@ -146,10 +151,6 @@ def compute_unit_state(unit):
|
||||
return UnitState.public
|
||||
|
||||
|
||||
def get_date_display(date):
|
||||
return date.strftime("%d %B, %Y at %I:%M %p")
|
||||
|
||||
|
||||
def update_item(location, value):
|
||||
"""
|
||||
If value is None, delete the db entry. Otherwise, update it using the correct modulestore.
|
||||
@@ -158,3 +159,67 @@ def update_item(location, value):
|
||||
get_modulestore(location).delete_item(location)
|
||||
else:
|
||||
get_modulestore(location).update_item(location, value)
|
||||
|
||||
|
||||
def get_url_reverse(course_page_name, course_module):
|
||||
"""
|
||||
Returns the course URL link to the specified location. This value is suitable to use as an href link.
|
||||
|
||||
course_page_name should correspond to an attribute in CoursePageNames (for example, 'ManageUsers'
|
||||
or 'SettingsDetails'), or else it will simply be returned. This method passes back unknown values of
|
||||
course_page_names so that it can also be used for absolute (known) URLs.
|
||||
|
||||
course_module is used to obtain the location, org, course, and name properties for a course, if
|
||||
course_page_name corresponds to an attribute in CoursePageNames.
|
||||
"""
|
||||
url_name = getattr(CoursePageNames, course_page_name, None)
|
||||
ctx_loc = course_module.location
|
||||
|
||||
if CoursePageNames.ManageUsers == url_name:
|
||||
return reverse(url_name, kwargs={"location": ctx_loc})
|
||||
elif url_name in [CoursePageNames.SettingsDetails, CoursePageNames.SettingsGrading,
|
||||
CoursePageNames.CourseOutline, CoursePageNames.Checklists]:
|
||||
return reverse(url_name, kwargs={'org': ctx_loc.org, 'course': ctx_loc.course, 'name': ctx_loc.name})
|
||||
else:
|
||||
return course_page_name
|
||||
|
||||
|
||||
class CoursePageNames:
|
||||
""" Constants for pages that are recognized by get_url_reverse method. """
|
||||
ManageUsers = "manage_users"
|
||||
SettingsDetails = "settings_details"
|
||||
SettingsGrading = "settings_grading"
|
||||
CourseOutline = "course_index"
|
||||
Checklists = "checklists"
|
||||
|
||||
def add_open_ended_panel_tab(course):
|
||||
"""
|
||||
Used to add the open ended panel tab to a course if it does not exist.
|
||||
@param course: A course object from the modulestore.
|
||||
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
|
||||
"""
|
||||
#Copy course tabs
|
||||
course_tabs = copy.copy(course.tabs)
|
||||
changed = False
|
||||
#Check to see if open ended panel is defined in the course
|
||||
if OPEN_ENDED_PANEL not in course_tabs:
|
||||
#Add panel to the tabs if it is not defined
|
||||
course_tabs.append(OPEN_ENDED_PANEL)
|
||||
changed = True
|
||||
return changed, course_tabs
|
||||
|
||||
def remove_open_ended_panel_tab(course):
|
||||
"""
|
||||
Used to remove the open ended panel tab from a course if it exists.
|
||||
@param course: A course object from the modulestore.
|
||||
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
|
||||
"""
|
||||
#Copy course tabs
|
||||
course_tabs = copy.copy(course.tabs)
|
||||
changed = False
|
||||
#Check to see if open ended panel is defined in the course
|
||||
if OPEN_ENDED_PANEL in course_tabs:
|
||||
#Add panel to the tabs if it is not defined
|
||||
course_tabs = [ct for ct in course_tabs if ct!=OPEN_ENDED_PANEL]
|
||||
changed = True
|
||||
return changed, course_tabs
|
||||
|
||||
@@ -6,7 +6,6 @@ import sys
|
||||
import time
|
||||
import tarfile
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from uuid import uuid4
|
||||
from path import path
|
||||
@@ -42,22 +41,24 @@ from xmodule.modulestore.mongo import MongoUsage
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from functools import partial
|
||||
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
|
||||
from auth.authz import is_user_in_course_group_role, get_users_in_course_group_by_role
|
||||
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
|
||||
from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups
|
||||
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState, get_course_for_item
|
||||
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, \
|
||||
UnitState, get_course_for_item, get_url_reverse, add_open_ended_panel_tab, \
|
||||
remove_open_ended_panel_tab
|
||||
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from contentstore.course_info_model import get_course_updates, \
|
||||
update_course_updates, delete_course_update
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from xmodule.timeparse import stringify_time
|
||||
from contentstore.module_info_model import get_module_info, set_module_info
|
||||
from models.settings.course_details import CourseDetails, \
|
||||
CourseSettingsEncoder
|
||||
@@ -73,7 +74,8 @@ log = logging.getLogger(__name__)
|
||||
|
||||
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
|
||||
|
||||
ADVANCED_COMPONENT_TYPES = ['annotatable', 'combinedopenended', 'peergrading']
|
||||
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
|
||||
ADVANCED_COMPONENT_TYPES = ['annotatable'] + OPEN_ENDED_COMPONENT_TYPES
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
@@ -144,10 +146,7 @@ def index(request):
|
||||
return render_to_response('index.html', {
|
||||
'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'),
|
||||
'courses': [(course.display_name,
|
||||
reverse('course_index', args=[
|
||||
course.location.org,
|
||||
course.location.course,
|
||||
course.location.name]),
|
||||
get_url_reverse('CourseOutline', course),
|
||||
get_lms_link_for_item(course.location, course_id=course.location.course_id))
|
||||
for course in courses],
|
||||
'user': request.user,
|
||||
@@ -184,21 +183,17 @@ def course_index(request, org, course, name):
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
lms_link = get_lms_link_for_item(location)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course = modulestore().get_item(location)
|
||||
course = modulestore().get_item(location, depth=3)
|
||||
sections = course.get_children()
|
||||
|
||||
return render_to_response('overview.html', {
|
||||
@@ -218,19 +213,14 @@ def course_index(request, org, course, name):
|
||||
@login_required
|
||||
def edit_subsection(request, location):
|
||||
# check that we have permissions to edit this item
|
||||
if not has_access(request.user, location):
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
# TODO: we need a smarter way to figure out what course an item is in
|
||||
for course in modulestore().get_courses():
|
||||
if (course.location.org == item.location.org and
|
||||
course.location.course == item.location.course):
|
||||
break
|
||||
|
||||
lms_link = get_lms_link_for_item(location)
|
||||
preview_link = get_lms_link_for_item(location, preview=True)
|
||||
lms_link = get_lms_link_for_item(location, course_id=course.location.course_id)
|
||||
preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True)
|
||||
|
||||
# make sure that location references a 'sequential', otherwise return BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
@@ -252,7 +242,7 @@ def edit_subsection(request, location):
|
||||
for field
|
||||
in item.fields
|
||||
if field.name not in ['display_name', 'start', 'due', 'format'] and
|
||||
field.scope == Scope.settings
|
||||
field.scope == Scope.settings
|
||||
)
|
||||
|
||||
can_view_live = False
|
||||
@@ -264,18 +254,18 @@ def edit_subsection(request, location):
|
||||
break
|
||||
|
||||
return render_to_response('edit_subsection.html',
|
||||
{'subsection': item,
|
||||
'context_course': course,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
})
|
||||
{'subsection': item,
|
||||
'context_course': course,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -287,19 +277,13 @@ def edit_unit(request, location):
|
||||
|
||||
id: A Location URL
|
||||
"""
|
||||
# check that we have permissions to edit this item
|
||||
if not has_access(request.user, location):
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
# TODO: we need a smarter way to figure out what course an item is in
|
||||
for course in modulestore().get_courses():
|
||||
if (course.location.org == item.location.org and
|
||||
course.location.course == item.location.course):
|
||||
break
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location)
|
||||
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
|
||||
@@ -384,7 +368,7 @@ def edit_unit(request, location):
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.lms.start))) if containing_subsection.lms.start is not None else None,
|
||||
'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None,
|
||||
'section': containing_section,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'unit_state': unit_state,
|
||||
@@ -458,9 +442,16 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
|
||||
# Let the module handle the AJAX
|
||||
try:
|
||||
ajax_return = instance.handle_ajax(dispatch, request.POST)
|
||||
|
||||
except NotFoundError:
|
||||
log.exception("Module indicating to user that request doesn't exist")
|
||||
raise Http404
|
||||
|
||||
except ProcessingError:
|
||||
log.warning("Module raised an error while processing AJAX request",
|
||||
exc_info=True)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
except:
|
||||
log.exception("error processing ajax call")
|
||||
raise
|
||||
@@ -801,9 +792,7 @@ def upload_asset(request, org, course, coursename):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# construct a location from the passed in path
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
if not has_access(request.user, location):
|
||||
return HttpResponseForbidden()
|
||||
location = get_location_and_verify_access(request, org, course, coursename)
|
||||
|
||||
# Does the course actually exist?!? Get anything from it to prove its existance
|
||||
|
||||
@@ -842,7 +831,7 @@ def upload_asset(request, org, course, coursename):
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_date_display(readback.last_modified_at),
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
@@ -955,11 +944,7 @@ def landing(request, org, course, coursename):
|
||||
@ensure_csrf_cookie
|
||||
def static_pages(request, org, course, coursename):
|
||||
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
location = get_location_and_verify_access(request, org, course, coursename)
|
||||
|
||||
course = modulestore().get_item(location)
|
||||
|
||||
@@ -1071,11 +1056,7 @@ def course_info(request, org, course, name, provided_id=None):
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
@@ -1114,11 +1095,7 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
|
||||
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if request.method == 'GET':
|
||||
return HttpResponse(json.dumps(get_course_updates(location)),
|
||||
@@ -1149,11 +1126,7 @@ def module_info(request, module_location):
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
|
||||
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
real_method = get_request_method(request)
|
||||
|
||||
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
|
||||
logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links))
|
||||
@@ -1178,11 +1151,7 @@ def get_course_settings(request, org, course, name):
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
@@ -1205,11 +1174,7 @@ def course_config_graders_page(request, org, course, name):
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseGradingModel.fetch(location)
|
||||
@@ -1229,11 +1194,7 @@ def course_config_advanced_page(request, org, course, name):
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
@@ -1255,11 +1216,7 @@ def course_settings_updates(request, org, course, name, section):
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
section: one of details, faculty, grading, problems, discussions
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if section == 'details':
|
||||
manager = CourseDetails
|
||||
@@ -1287,27 +1244,20 @@ def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(['i4x', org, course, 'course', name]), grader_index)),
|
||||
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(location), grader_index)),
|
||||
mimetype="application/json")
|
||||
elif real_method == "DELETE":
|
||||
# ??? Shoudl this return anything? Perhaps success fail?
|
||||
CourseGradingModel.delete_grader(Location(['i4x', org, course, 'course', name]), grader_index)
|
||||
# ??? Should this return anything? Perhaps success fail?
|
||||
CourseGradingModel.delete_grader(Location(location), grader_index)
|
||||
return HttpResponse()
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)),
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(location), request.POST)),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
@@ -1321,25 +1271,139 @@ def course_advanced_updates(request, org, course, name):
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
|
||||
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_method == 'GET':
|
||||
return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json")
|
||||
elif real_method == 'DELETE':
|
||||
return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json")
|
||||
return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))),
|
||||
mimetype="application/json")
|
||||
elif real_method == 'POST' or real_method == 'PUT':
|
||||
# NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
|
||||
return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json")
|
||||
request_body = json.loads(request.body)
|
||||
#Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
#Check to see if the user instantiated any advanced components. This is a hack to add the open ended panel tab
|
||||
#to a course automatically if the user has indicated that they want to edit the combinedopenended or peergrading
|
||||
#module, and to remove it if they have removed the open ended elements.
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request_body:
|
||||
#Check to see if the user instantiated any open ended components
|
||||
found_oe_type = False
|
||||
#Get the course so that we can scrape current tabs
|
||||
course_module = modulestore().get_item(location)
|
||||
for oe_type in OPEN_ENDED_COMPONENT_TYPES:
|
||||
if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
#Add an open ended tab to the course if needed
|
||||
changed, new_tabs = add_open_ended_panel_tab(course_module)
|
||||
#If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
#Set this flag to avoid the open ended tab removal code below.
|
||||
found_oe_type = True
|
||||
break
|
||||
#If we did not find an open ended module type in the advanced settings,
|
||||
# we may need to remove the open ended tab from the course.
|
||||
if not found_oe_type:
|
||||
#Remove open ended tab to the course if needed
|
||||
changed, new_tabs = remove_open_ended_panel_tab(course_module)
|
||||
if changed:
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs))
|
||||
return HttpResponse(response_json, mimetype="application/json")
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def get_checklists(request, org, course, name):
|
||||
"""
|
||||
Send models, views, and html for displaying the course checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
modulestore = get_modulestore(location)
|
||||
course_module = modulestore.get_item(location)
|
||||
new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
|
||||
template_module = modulestore.get_item(new_course_template)
|
||||
|
||||
# If course was created before checklists were introduced, copy them over from the template.
|
||||
copied = False
|
||||
if not course_module.checklists:
|
||||
course_module.checklists = template_module.checklists
|
||||
copied = True
|
||||
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if copied or modified:
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return render_to_response('checklists.html',
|
||||
{
|
||||
'context_course': course_module,
|
||||
'checklists': checklists
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def update_checklist(request, org, course, name, checklist_index=None):
|
||||
"""
|
||||
restful CRUD operations on course checklists. The payload is a json rep of
|
||||
the modified checklist. For PUT or POST requests, the index of the
|
||||
checklist being modified must be included; the returned payload will
|
||||
be just that one checklist. For GET requests, the returned payload
|
||||
is a json representation of the list of all checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
modulestore = get_modulestore(location)
|
||||
course_module = modulestore.get_item(location)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
if real_method == 'POST' or real_method == 'PUT':
|
||||
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
|
||||
index = int(checklist_index)
|
||||
course_module.checklists[index] = json.loads(request.body)
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists[index]), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
"Could not save checklist state because the checklist index was out of range or unspecified.",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'GET':
|
||||
# In the JavaScript view initialize method, we do a fetch to get all the checklists.
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if modified:
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest("Unsupported request.", content_type="text/plain")
|
||||
|
||||
|
||||
def expand_checklist_action_urls(course_module):
|
||||
"""
|
||||
Gets the checklists out of the course module and expands their action urls
|
||||
if they have not yet been expanded.
|
||||
|
||||
Returns the checklists with modified urls, as well as a boolean
|
||||
indicating whether or not the checklists were modified.
|
||||
"""
|
||||
checklists = course_module.checklists
|
||||
modified = False
|
||||
for checklist in checklists:
|
||||
if not checklist.get('action_urls_expanded', False):
|
||||
for item in checklist.get('items'):
|
||||
item['action_url'] = get_url_reverse(item.get('action_url'), course_module)
|
||||
checklist['action_urls_expanded'] = True
|
||||
modified = True
|
||||
|
||||
return checklists, modified
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -1350,18 +1414,13 @@ def asset_index(request, org, course, name):
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
@@ -1377,7 +1436,7 @@ def asset_index(request, org, course, name):
|
||||
id = asset['_id']
|
||||
display_info = {}
|
||||
display_info['displayname'] = asset['displayname']
|
||||
display_info['uploadDate'] = get_date_display(asset['uploadDate'])
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple())
|
||||
|
||||
asset_location = StaticContent.compute_location(id['org'], id['course'], id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
@@ -1477,11 +1536,7 @@ def initialize_course_tabs(course):
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if request.method == 'POST':
|
||||
filename = request.FILES['course-data'].name
|
||||
@@ -1544,20 +1599,14 @@ def import_course(request, org, course, name):
|
||||
return render_to_response('import.html', {
|
||||
'context_course': course_module,
|
||||
'active_tab': 'import',
|
||||
'successful_import_redirect_url': reverse('course_index', args=[
|
||||
course_module.location.org,
|
||||
course_module.location.course,
|
||||
course_module.location.name])
|
||||
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def generate_export_course(request, org, course, name):
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
loc = Location(location)
|
||||
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
|
||||
@@ -1590,11 +1639,9 @@ def generate_export_course(request, org, course, name):
|
||||
@login_required
|
||||
def export_course(request, org, course, name):
|
||||
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
return render_to_response('export.html', {
|
||||
'context_course': course_module,
|
||||
@@ -1617,3 +1664,31 @@ def render_404(request):
|
||||
|
||||
def render_500(request):
|
||||
return HttpResponseServerError(render_to_string('500.html', {}))
|
||||
|
||||
|
||||
def get_location_and_verify_access(request, org, course, name):
|
||||
"""
|
||||
Create the location tuple verify that the user has permissions
|
||||
to view the location. Returns the location.
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
return location
|
||||
|
||||
|
||||
def get_request_method(request):
|
||||
"""
|
||||
Using HTTP_X_HTTP_METHOD_OVERRIDE, in the request metadata, determine
|
||||
what type of request came from the client, and return it.
|
||||
"""
|
||||
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
|
||||
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
|
||||
return real_method
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
@@ -6,9 +5,9 @@ import json
|
||||
from json.encoder import JSONEncoder
|
||||
import time
|
||||
from contentstore.utils import get_modulestore
|
||||
from util.converters import jsdate_to_time, time_to_date
|
||||
from models.settings import course_grading
|
||||
from contentstore.utils import update_item
|
||||
from xmodule.fields import Date
|
||||
import re
|
||||
import logging
|
||||
|
||||
@@ -81,8 +80,14 @@ class CourseDetails(object):
|
||||
|
||||
dirty = False
|
||||
|
||||
# In the descriptor's setter, the date is converted to JSON using Date's to_json method.
|
||||
# Calling to_json on something that is already JSON doesn't work. Since reaching directly
|
||||
# into the model is nasty, convert the JSON Date to a Python date, which is what the
|
||||
# setter expects as input.
|
||||
date = Date()
|
||||
|
||||
if 'start_date' in jsondict:
|
||||
converted = jsdate_to_time(jsondict['start_date'])
|
||||
converted = date.from_json(jsondict['start_date'])
|
||||
else:
|
||||
converted = None
|
||||
if converted != descriptor.start:
|
||||
@@ -90,7 +95,7 @@ class CourseDetails(object):
|
||||
descriptor.start = converted
|
||||
|
||||
if 'end_date' in jsondict:
|
||||
converted = jsdate_to_time(jsondict['end_date'])
|
||||
converted = date.from_json(jsondict['end_date'])
|
||||
else:
|
||||
converted = None
|
||||
|
||||
@@ -99,7 +104,7 @@ class CourseDetails(object):
|
||||
descriptor.end = converted
|
||||
|
||||
if 'enrollment_start' in jsondict:
|
||||
converted = jsdate_to_time(jsondict['enrollment_start'])
|
||||
converted = date.from_json(jsondict['enrollment_start'])
|
||||
else:
|
||||
converted = None
|
||||
|
||||
@@ -108,7 +113,7 @@ class CourseDetails(object):
|
||||
descriptor.enrollment_start = converted
|
||||
|
||||
if 'enrollment_end' in jsondict:
|
||||
converted = jsdate_to_time(jsondict['enrollment_end'])
|
||||
converted = date.from_json(jsondict['enrollment_end'])
|
||||
else:
|
||||
converted = None
|
||||
|
||||
@@ -178,6 +183,6 @@ class CourseSettingsEncoder(json.JSONEncoder):
|
||||
elif isinstance(obj, Location):
|
||||
return obj.dict()
|
||||
elif isinstance(obj, time.struct_time):
|
||||
return time_to_date(obj)
|
||||
return Date().to_json(obj)
|
||||
else:
|
||||
return JSONEncoder.default(self, obj)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
import re
|
||||
from util import converters
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
|
||||
@@ -3,19 +3,24 @@ from contentstore.utils import get_modulestore
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xblock.core import Scope
|
||||
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
import copy
|
||||
|
||||
class CourseMetadata(object):
|
||||
'''
|
||||
For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones.
|
||||
The objects have no predefined attrs but instead are obj encodings of the editable metadata.
|
||||
For CRUD operations on metadata fields which do not have specific editors
|
||||
on the other pages including any user generated ones.
|
||||
The objects have no predefined attrs but instead are obj encodings of the
|
||||
editable metadata.
|
||||
'''
|
||||
FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod']
|
||||
FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end',
|
||||
'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', 'checklists']
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
"""
|
||||
Fetch the key:value editable course details for the given course from persistence and return a CourseMetadata model.
|
||||
Fetch the key:value editable course details for the given course from
|
||||
persistence and return a CourseMetadata model.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
@@ -29,12 +34,12 @@ class CourseMetadata(object):
|
||||
continue
|
||||
|
||||
if field.name not in cls.FILTERED_LIST:
|
||||
course[field.name] = field.read_from(descriptor)
|
||||
course[field.name] = field.read_json(descriptor)
|
||||
|
||||
return course
|
||||
|
||||
@classmethod
|
||||
def update_from_json(cls, course_location, jsondict):
|
||||
def update_from_json(cls, course_location, jsondict, filter_tabs=True):
|
||||
"""
|
||||
Decode the json into CourseMetadata and save any changed attrs to the db.
|
||||
|
||||
@@ -43,30 +48,40 @@ class CourseMetadata(object):
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
dirty = False
|
||||
|
||||
#Copy the filtered list to avoid permanently changing the class attribute
|
||||
filtered_list = copy.copy(cls.FILTERED_LIST)
|
||||
#Don't filter on the tab attribute if filter_tabs is False
|
||||
if not filter_tabs:
|
||||
filtered_list.remove("tabs")
|
||||
|
||||
for k, v in jsondict.iteritems():
|
||||
# should it be an error if one of the filtered list items is in the payload?
|
||||
if k in cls.FILTERED_LIST:
|
||||
if k in filtered_list:
|
||||
continue
|
||||
|
||||
if hasattr(descriptor, k) and getattr(descriptor, k) != v:
|
||||
dirty = True
|
||||
setattr(descriptor, k, v)
|
||||
value = getattr(CourseDescriptor, k).from_json(v)
|
||||
setattr(descriptor, k, value)
|
||||
elif hasattr(descriptor.lms, k) and getattr(descriptor.lms, k) != k:
|
||||
dirty = True
|
||||
setattr(descriptor.lms, k, v)
|
||||
value = getattr(CourseDescriptor.lms, k).from_json(v)
|
||||
setattr(descriptor.lms, k, value)
|
||||
|
||||
if dirty:
|
||||
get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor))
|
||||
get_modulestore(course_location).update_metadata(course_location,
|
||||
own_metadata(descriptor))
|
||||
|
||||
# Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm
|
||||
# it persisted correctly
|
||||
# Could just generate and return a course obj w/o doing any db reads,
|
||||
# but I put the reads in as a means to confirm it persisted correctly
|
||||
return cls.fetch(course_location)
|
||||
|
||||
@classmethod
|
||||
def delete_key(cls, course_location, payload):
|
||||
'''
|
||||
Remove the given metadata key(s) from the course. payload can be a single key or [key..]
|
||||
Remove the given metadata key(s) from the course. payload can be a
|
||||
single key or [key..]
|
||||
'''
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
@@ -76,6 +91,7 @@ class CourseMetadata(object):
|
||||
elif hasattr(descriptor.lms, key):
|
||||
delattr(descriptor.lms, key)
|
||||
|
||||
get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor))
|
||||
get_modulestore(course_location).update_metadata(course_location,
|
||||
own_metadata(descriptor))
|
||||
|
||||
return cls.fetch(course_location)
|
||||
|
||||
@@ -34,6 +34,7 @@ MITX_FEATURES = {
|
||||
'ENABLE_DISCUSSION_SERVICE': False,
|
||||
'AUTH_USE_MIT_CERTIFICATES': False,
|
||||
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
|
||||
'STUDIO_NPS_SURVEY': True,
|
||||
}
|
||||
ENABLE_JASMINE = False
|
||||
|
||||
@@ -113,6 +114,7 @@ TEMPLATE_LOADERS = (
|
||||
|
||||
MIDDLEWARE_CLASSES = (
|
||||
'contentserver.middleware.StaticContentServer',
|
||||
'request_cache.middleware.RequestCache',
|
||||
'django.middleware.cache.UpdateCacheMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
|
||||
@@ -112,6 +112,10 @@ CACHE_TIMEOUT = 0
|
||||
# Dummy secret key for dev
|
||||
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
|
||||
|
||||
################################ PIPELINE #################################
|
||||
|
||||
PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT)
|
||||
|
||||
################################ DEBUG TOOLBAR #################################
|
||||
INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo')
|
||||
MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
|
||||
@@ -142,4 +146,7 @@ DEBUG_TOOLBAR_CONFIG = {
|
||||
|
||||
# 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
|
||||
DEBUG_TOOLBAR_MONGO_STACKTRACES = True
|
||||
|
||||
# disable NPS survey in dev mode
|
||||
MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
|
||||
|
||||
@@ -58,6 +58,10 @@ MODULESTORE = {
|
||||
'direct': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': modulestore_options
|
||||
},
|
||||
'draft': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
|
||||
'OPTIONS': modulestore_options
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
from dogapi import dog_http_api, dog_stats_api
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from request_cache.middleware import RequestCache
|
||||
|
||||
from django.core.cache import get_cache, InvalidCacheBackendError
|
||||
|
||||
cache = get_cache('mongo_metadata_inheritance')
|
||||
for store_name in settings.MODULESTORE:
|
||||
store = modulestore(store_name)
|
||||
store.metadata_inheritance_cache = cache
|
||||
store.metadata_inheritance_cache_subsystem = cache
|
||||
store.request_cache = RequestCache.get_request_cache()
|
||||
|
||||
if hasattr(settings, 'DATADOG_API'):
|
||||
dog_http_api.api_key = settings.DATADOG_API
|
||||
|
||||
61
cms/static/client_templates/checklist.html
Normal file
61
cms/static/client_templates/checklist.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<% var allChecked = itemsChecked == items.length; %>
|
||||
<section
|
||||
<% if (allChecked) { %>
|
||||
class="course-checklist is-completed"
|
||||
<% } else { %>
|
||||
class="course-checklist"
|
||||
<% } %>
|
||||
id="<%= 'course-checklist' + checklistIndex %>">
|
||||
<% var widthPercentage = 'width:' + percentChecked + '%;'; %>
|
||||
<span class="viz viz-checklist-status"><span class="viz value viz-checklist-status-value" style="<%= widthPercentage %>">
|
||||
<span class="int"><%= percentChecked %></span>% of checklist completed</span></span>
|
||||
<header>
|
||||
<h3 class="checklist-title title-2 is-selectable" title="Collapse/Expand this Checklist">
|
||||
<i class="ss-icon ss-symbolicons-standard icon-arrow ui-toggle-expansion">▾</i>
|
||||
<%= checklistShortDescription %></h3>
|
||||
<span class="checklist-status status">
|
||||
Tasks Completed: <span class="status-count"><%= itemsChecked %></span>/<span class="status-amount"><%= items.length %></span>
|
||||
<i class="ss-icon ss-symbolicons-standard icon-confirm">✓</i>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ul class="list list-tasks">
|
||||
<% var taskIndex = 0; %>
|
||||
<% _.each(items, function(item) { %>
|
||||
<% var checked = item['is_checked']; %>
|
||||
<li
|
||||
<% if (checked) { %>
|
||||
class="task is-completed"
|
||||
<% } else { %>
|
||||
class="task"
|
||||
<% } %>
|
||||
>
|
||||
<% var taskId = 'course-checklist' + checklistIndex + '-task' + taskIndex; %>
|
||||
<input type="checkbox" class="task-input" data-checklist="<%= checklistIndex %>" data-task="<%= taskIndex %>"
|
||||
name="<%= taskId %>" id="<%= taskId %>"
|
||||
<% if (checked) { %>
|
||||
checked="checked"
|
||||
<% } %>
|
||||
>
|
||||
<label class="task-details" for="<%= taskId %>">
|
||||
<h4 class="task-name title title-3"><%= item['short_description'] %></h4>
|
||||
<p class="task-description"><%= item['long_description'] %></p>
|
||||
</label>
|
||||
|
||||
<% if (item['action_text'] !== '' && item['action_url'] !== '') { %>
|
||||
<ul class="list-actions task-actions">
|
||||
<li>
|
||||
<a href="<%= item['action_url'] %>" class="action action-primary"
|
||||
<% if (item['action_external']) { %>
|
||||
rel="external" title="This link will open in a new browser window/tab"
|
||||
<% } %>
|
||||
><%= item['action_text'] %></a>
|
||||
</li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</li>
|
||||
|
||||
<% taskIndex+=1; }) %>
|
||||
|
||||
</ul>
|
||||
</section>
|
||||
@@ -167,7 +167,7 @@ class CMS.Views.UnitEdit extends Backbone.View
|
||||
|
||||
class CMS.Views.UnitEdit.NameEdit extends Backbone.View
|
||||
events:
|
||||
"keyup .unit-display-name-input": "saveName"
|
||||
'change .unit-display-name-input': 'saveName'
|
||||
|
||||
initialize: =>
|
||||
@model.on('change:metadata', @render)
|
||||
@@ -190,29 +190,10 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View
|
||||
# Treat the metadata dictionary as immutable
|
||||
metadata = $.extend({}, @model.get('metadata'))
|
||||
metadata.display_name = @$('.unit-display-name-input').val()
|
||||
@model.save(metadata: metadata)
|
||||
# Update name shown in the right-hand side location summary.
|
||||
$('.unit-location .editing .unit-name').html(metadata.display_name)
|
||||
|
||||
inputField = this.$el.find('input')
|
||||
|
||||
# add a spinner
|
||||
@$spinner.css({
|
||||
'position': 'absolute',
|
||||
'top': Math.floor(inputField.position().top + (inputField.outerHeight() / 2) + 3),
|
||||
'left': inputField.position().left + inputField.outerWidth() - 24,
|
||||
'margin-top': '-10px'
|
||||
});
|
||||
inputField.after(@$spinner);
|
||||
@$spinner.fadeIn(10)
|
||||
|
||||
# save the name after a slight delay
|
||||
if @timer
|
||||
clearTimeout @timer
|
||||
@timer = setTimeout( =>
|
||||
@model.save(metadata: metadata)
|
||||
@timer = null
|
||||
@$spinner.delay(500).fadeOut(150)
|
||||
, 500)
|
||||
|
||||
class CMS.Views.UnitEdit.LocationState extends Backbone.View
|
||||
initialize: =>
|
||||
@model.on('change:state', @render)
|
||||
|
||||
@@ -4,6 +4,9 @@ var $modalCover;
|
||||
var $newComponentItem;
|
||||
var $changedInput;
|
||||
var $spinner;
|
||||
var $newComponentTypePicker;
|
||||
var $newComponentTemplatePickers;
|
||||
var $newComponentButton;
|
||||
|
||||
$(document).ready(function () {
|
||||
$body = $('body');
|
||||
@@ -14,10 +17,6 @@ $(document).ready(function () {
|
||||
// scopes (namely the course-info tab)
|
||||
window.$modalCover = $modalCover;
|
||||
|
||||
// Control whether template caching in local memory occurs (see template_loader.js). Caching screws up development but may
|
||||
// be a good optimization in production (it works fairly well)
|
||||
window.cachetemplates = false;
|
||||
|
||||
$body.append($modalCover);
|
||||
$newComponentItem = $('.new-component-item');
|
||||
$newComponentTypePicker = $('.new-component');
|
||||
@@ -101,10 +100,7 @@ $(document).ready(function () {
|
||||
});
|
||||
|
||||
// general link management - new window/tab
|
||||
$('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').click(function (e) {
|
||||
window.open($(this).attr('href'));
|
||||
e.preventDefault();
|
||||
});
|
||||
$('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').bind('click', linkNewWindow);
|
||||
|
||||
// general link management - lean modal window
|
||||
$('a[rel="modal"]').attr('title', 'This link will open in a modal window').leanModal({overlay: 0.50, closeButton: '.action-modal-close' });
|
||||
@@ -112,6 +108,12 @@ $(document).ready(function () {
|
||||
(e).preventDefault();
|
||||
});
|
||||
|
||||
// general link management - smooth scrolling page links
|
||||
$('a[rel*="view"][href^="#"]').bind('click', smoothScrollLink);
|
||||
|
||||
// tender feedback window scrolling
|
||||
$('a.show-tender').bind('click', smoothScrollTop);
|
||||
|
||||
// toggling overview section details
|
||||
$(function () {
|
||||
if ($('.courseware-section').length > 0) {
|
||||
@@ -120,9 +122,9 @@ $(document).ready(function () {
|
||||
});
|
||||
$('.toggle-button-sections').bind('click', toggleSections);
|
||||
|
||||
// autosave when a field is updated on the subsection page
|
||||
$body.on('keyup', '.subsection-display-name-input, .unit-subtitle, .policy-list-value', checkForNewValue);
|
||||
$('.subsection-display-name-input, .unit-subtitle, .policy-list-name, .policy-list-value').each(function (i) {
|
||||
// autosave when leaving input field
|
||||
$body.on('change', '.subsection-display-name-input', saveSubsection);
|
||||
$('.subsection-display-name-input').each(function () {
|
||||
this.val = $(this).val();
|
||||
});
|
||||
$("#start_date, #start_time, #due_date, #due_time").bind('change', autosaveInput);
|
||||
@@ -138,11 +140,6 @@ $(document).ready(function () {
|
||||
// add new/delete subsection
|
||||
$('.new-subsection-item').bind('click', addNewSubsection);
|
||||
$('.delete-subsection-button').bind('click', deleteSubsection);
|
||||
// add/remove policy metadata button click handlers
|
||||
$('.add-policy-data').bind('click', addPolicyMetadata);
|
||||
$('.remove-policy-data').bind('click', removePolicyMetadata);
|
||||
$body.on('click', '.policy-list-element .save-button', savePolicyMetadata);
|
||||
$body.on('click', '.policy-list-element .cancel-button', cancelPolicyMetadata);
|
||||
|
||||
$('.sync-date').bind('click', syncReleaseDate);
|
||||
|
||||
@@ -181,10 +178,39 @@ $(document).ready(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// function collapseAll(e) {
|
||||
// $('.branch').addClass('collapsed');
|
||||
// $('.expand-collapse-icon').removeClass('collapse').addClass('expand');
|
||||
// }
|
||||
function smoothScrollLink(e) {
|
||||
(e).preventDefault();
|
||||
|
||||
$.smoothScroll({
|
||||
offset: -200,
|
||||
easing: 'swing',
|
||||
speed: 1000,
|
||||
scrollElement: null,
|
||||
scrollTarget: $(this).attr('href')
|
||||
});
|
||||
}
|
||||
|
||||
function smoothScrollTop(e) {
|
||||
(e).preventDefault();
|
||||
|
||||
$.smoothScroll({
|
||||
offset: -200,
|
||||
easing: 'swing',
|
||||
speed: 1000,
|
||||
scrollElement: null,
|
||||
scrollTarget: $('#view-top')
|
||||
});
|
||||
}
|
||||
|
||||
function linkNewWindow(e) {
|
||||
window.open($(e.target).attr('href'));
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// On AWS instances, base.js gets wrapped in a separate scope as part of Django static
|
||||
// pipelining (note, this doesn't happen on local runtimes). So if we set it on window,
|
||||
// when we can access it from other scopes (namely the checklists)
|
||||
window.cmsLinkNewWindow = linkNewWindow;
|
||||
|
||||
function toggleSections(e) {
|
||||
e.preventDefault();
|
||||
@@ -244,57 +270,7 @@ function syncReleaseDate(e) {
|
||||
$("#start_time").val("");
|
||||
}
|
||||
|
||||
function addPolicyMetadata(e) {
|
||||
e.preventDefault();
|
||||
var template = $('#add-new-policy-element-template > li');
|
||||
var newNode = template.clone();
|
||||
var _parent_el = $(this).parent('ol:.policy-list');
|
||||
newNode.insertBefore('.add-policy-data');
|
||||
$('.remove-policy-data').bind('click', removePolicyMetadata);
|
||||
newNode.find('.policy-list-name').focus();
|
||||
}
|
||||
|
||||
function savePolicyMetadata(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $policyElement = $(this).parents('.policy-list-element');
|
||||
saveSubsection()
|
||||
$policyElement.removeClass('new-policy-list-element');
|
||||
$policyElement.find('.policy-list-name').attr('disabled', 'disabled');
|
||||
$policyElement.removeClass('editing');
|
||||
}
|
||||
|
||||
function cancelPolicyMetadata(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $policyElement = $(this).parents('.policy-list-element');
|
||||
if (!$policyElement.hasClass('editing')) {
|
||||
$policyElement.remove();
|
||||
} else {
|
||||
$policyElement.removeClass('new-policy-list-element');
|
||||
$policyElement.find('.policy-list-name').val($policyElement.data('currentValues')[0]);
|
||||
$policyElement.find('.policy-list-value').val($policyElement.data('currentValues')[1]);
|
||||
}
|
||||
$policyElement.removeClass('editing');
|
||||
}
|
||||
|
||||
function removePolicyMetadata(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!confirm('Are you sure you wish to delete this item. It cannot be reversed!'))
|
||||
return;
|
||||
|
||||
policy_name = $(this).data('policy-name');
|
||||
var _parent_el = $(this).parent('li:.policy-list-element');
|
||||
if ($(_parent_el).hasClass("new-policy-list-element")) {
|
||||
_parent_el.remove();
|
||||
} else {
|
||||
_parent_el.appendTo("#policy-to-delete");
|
||||
}
|
||||
saveSubsection()
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeVals(date_val, time_val, format) {
|
||||
function getEdxTimeFromDateTimeVals(date_val, time_val) {
|
||||
var edxTimeStr = null;
|
||||
|
||||
if (date_val != '') {
|
||||
@@ -302,49 +278,22 @@ function getEdxTimeFromDateTimeVals(date_val, time_val, format) {
|
||||
time_val = '00:00';
|
||||
|
||||
// Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing
|
||||
date = Date.parse(date_val + " " + time_val);
|
||||
if (format == null)
|
||||
format = 'yyyy-MM-ddTHH:mm';
|
||||
|
||||
edxTimeStr = date.toString(format);
|
||||
var date = Date.parse(date_val + " " + time_val);
|
||||
edxTimeStr = date.toString('yyyy-MM-ddTHH:mm');
|
||||
}
|
||||
|
||||
return edxTimeStr;
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeInputs(date_id, time_id, format) {
|
||||
function getEdxTimeFromDateTimeInputs(date_id, time_id) {
|
||||
var input_date = $('#' + date_id).val();
|
||||
var input_time = $('#' + time_id).val();
|
||||
|
||||
return getEdxTimeFromDateTimeVals(input_date, input_time, format);
|
||||
}
|
||||
|
||||
function checkForNewValue(e) {
|
||||
if ($(this).parents('.new-policy-list-element')[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.val) {
|
||||
this.hasChanged = this.val != $(this).val();
|
||||
} else {
|
||||
this.hasChanged = false;
|
||||
}
|
||||
|
||||
this.val = $(this).val();
|
||||
if (this.hasChanged) {
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
}
|
||||
|
||||
this.saveTimer = setTimeout(function () {
|
||||
$changedInput = $(e.target);
|
||||
saveSubsection();
|
||||
this.saveTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
return getEdxTimeFromDateTimeVals(input_date, input_time);
|
||||
}
|
||||
|
||||
function autosaveInput(e) {
|
||||
var self = this;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
}
|
||||
@@ -352,11 +301,12 @@ function autosaveInput(e) {
|
||||
this.saveTimer = setTimeout(function () {
|
||||
$changedInput = $(e.target);
|
||||
saveSubsection();
|
||||
this.saveTimer = null;
|
||||
self.saveTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function saveSubsection() {
|
||||
// Spinner is no longer used by subsection name, but is still used by date and time pickers on the right.
|
||||
if ($changedInput && !$changedInput.hasClass('no-spinner')) {
|
||||
$spinner.css({
|
||||
'position': 'absolute',
|
||||
@@ -379,25 +329,9 @@ function saveSubsection() {
|
||||
metadata[$(el).data("metadata-name")] = el.value;
|
||||
}
|
||||
|
||||
// now add 'free-formed' metadata which are presented to the user as dual input fields (name/value)
|
||||
$('ol.policy-list > li.policy-list-element').each(function (i, element) {
|
||||
var name = $(element).children('.policy-list-name').val();
|
||||
metadata[name] = $(element).children('.policy-list-value').val();
|
||||
});
|
||||
|
||||
// now add any 'removed' policy metadata which is stored in a separate hidden div
|
||||
// 'null' presented to the server means 'remove'
|
||||
$("#policy-to-delete > li.policy-list-element").each(function (i, element) {
|
||||
var name = $(element).children('.policy-list-name').val();
|
||||
if (name != "")
|
||||
metadata[name] = null;
|
||||
});
|
||||
|
||||
// Piece back together the date/time UI elements into one date/time string
|
||||
// NOTE: our various "date/time" metadata elements don't always utilize the same formatting string
|
||||
// so make sure we're passing back the correct format
|
||||
metadata['start'] = getEdxTimeFromDateTimeInputs('start_date', 'start_time');
|
||||
metadata['due'] = getEdxTimeFromDateTimeInputs('due_date', 'due_time', 'MMMM dd HH:mm');
|
||||
metadata['due'] = getEdxTimeFromDateTimeInputs('due_date', 'due_time');
|
||||
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
@@ -407,6 +341,7 @@ function saveSubsection() {
|
||||
data: JSON.stringify({ 'id': id, 'metadata': metadata}),
|
||||
success: function () {
|
||||
$spinner.delay(500).fadeOut(150);
|
||||
$changedInput = null;
|
||||
},
|
||||
error: function () {
|
||||
showToastMessage('There has been an error while saving your changes.');
|
||||
@@ -418,8 +353,8 @@ function saveSubsection() {
|
||||
function createNewUnit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
parent = $(this).data('parent');
|
||||
template = $(this).data('template');
|
||||
var parent = $(this).data('parent');
|
||||
var template = $(this).data('template');
|
||||
|
||||
$.post('/clone_item',
|
||||
{'parent_location': parent,
|
||||
|
||||
24
cms/static/js/models/checklists.js
Normal file
24
cms/static/js/models/checklists.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Model for checklists_view.js.
|
||||
CMS.Models.Checklist = Backbone.Model.extend({
|
||||
});
|
||||
|
||||
CMS.Models.ChecklistCollection = Backbone.Collection.extend({
|
||||
model : CMS.Models.Checklist,
|
||||
|
||||
parse: function(response) {
|
||||
_.each(response,
|
||||
function( element, idx ) {
|
||||
element.id = idx;
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
// Disable caching so the browser back button will work (checklists have links to other
|
||||
// places within Studio).
|
||||
fetch: function (options) {
|
||||
options.cache = false;
|
||||
return Backbone.Collection.prototype.fetch.call(this, options);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,6 +37,9 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
|
||||
// Returns either nothing (no return call) so that validate works or an object of {field: errorstring} pairs
|
||||
// A bit funny in that the video key validation is asynchronous; so, it won't stop the validation.
|
||||
var errors = {};
|
||||
if (newattrs.start_date === null) {
|
||||
errors.start_date = "The course must have an assigned start date.";
|
||||
}
|
||||
if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) {
|
||||
errors.end_date = "The course end date cannot be before the course start date.";
|
||||
}
|
||||
|
||||
@@ -1,78 +1,79 @@
|
||||
// <!-- from https://github.com/Gazler/Underscore-Template-Loader/blob/master/index.html -->
|
||||
// TODO Figure out how to initialize w/ static views from server (don't call .load but instead inject in django as strings)
|
||||
// so this only loads the lazily loaded ones.
|
||||
(function() {
|
||||
if (typeof window.templateLoader == 'function') return;
|
||||
|
||||
var templateLoader = {
|
||||
templateVersion: "0.0.16",
|
||||
templates: {},
|
||||
loadRemoteTemplate: function(templateName, filename, callback) {
|
||||
if (!this.templates[templateName]) {
|
||||
var self = this;
|
||||
jQuery.ajax({url : filename,
|
||||
success : function(data) {
|
||||
self.addTemplate(templateName, data);
|
||||
self.saveLocalTemplates();
|
||||
callback(data);
|
||||
},
|
||||
error : function(xhdr, textStatus, errorThrown) {
|
||||
console.log(textStatus); },
|
||||
dataType : "html"
|
||||
})
|
||||
}
|
||||
else {
|
||||
callback(this.templates[templateName]);
|
||||
}
|
||||
},
|
||||
|
||||
addTemplate: function(templateName, data) {
|
||||
// is there a reason this doesn't go ahead and compile the template? _.template(data)
|
||||
// I suppose localstorage use would still req raw string rather than compiled version, but that sd work
|
||||
// if it maintains a separate cache of uncompiled ones
|
||||
this.templates[templateName] = data;
|
||||
},
|
||||
|
||||
localStorageAvailable: function() {
|
||||
try {
|
||||
// window.cachetemplates is global set in base.js to turn caching on/off
|
||||
return window.cachetemplates && 'localStorage' in window && window['localStorage'] !== null;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
saveLocalTemplates: function() {
|
||||
if (this.localStorageAvailable) {
|
||||
localStorage.setItem("templates", JSON.stringify(this.templates));
|
||||
localStorage.setItem("templateVersion", this.templateVersion);
|
||||
}
|
||||
},
|
||||
|
||||
loadLocalTemplates: function() {
|
||||
if (this.localStorageAvailable) {
|
||||
var templateVersion = localStorage.getItem("templateVersion");
|
||||
if (templateVersion && templateVersion == this.templateVersion) {
|
||||
var templates = localStorage.getItem("templates");
|
||||
if (templates) {
|
||||
templates = JSON.parse(templates);
|
||||
for (var x in templates) {
|
||||
if (!this.templates[x]) {
|
||||
this.addTemplate(x, templates[x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
localStorage.removeItem("templates");
|
||||
localStorage.removeItem("templateVersion");
|
||||
}
|
||||
}
|
||||
}
|
||||
(function () {
|
||||
if (typeof window.templateLoader == 'function') return;
|
||||
|
||||
var templateLoader = {
|
||||
templateVersion: "0.0.15",
|
||||
templates: {},
|
||||
// Control whether template caching in local memory occurs. Caching screws up development but may
|
||||
// be a good optimization in production (it works fairly well).
|
||||
cacheTemplates: false,
|
||||
loadRemoteTemplate: function (templateName, filename, callback) {
|
||||
if (!this.templates[templateName]) {
|
||||
var self = this;
|
||||
jQuery.ajax({url: filename,
|
||||
success: function (data) {
|
||||
self.addTemplate(templateName, data);
|
||||
self.saveLocalTemplates();
|
||||
callback(data);
|
||||
},
|
||||
error: function (xhdr, textStatus, errorThrown) {
|
||||
console.log(textStatus);
|
||||
},
|
||||
dataType: "html"
|
||||
})
|
||||
}
|
||||
else {
|
||||
callback(this.templates[templateName]);
|
||||
}
|
||||
},
|
||||
|
||||
addTemplate: function (templateName, data) {
|
||||
// is there a reason this doesn't go ahead and compile the template? _.template(data)
|
||||
// I suppose localstorage use would still req raw string rather than compiled version, but that sd work
|
||||
// if it maintains a separate cache of uncompiled ones
|
||||
this.templates[templateName] = data;
|
||||
},
|
||||
|
||||
localStorageAvailable: function () {
|
||||
try {
|
||||
return this.cacheTemplates && 'localStorage' in window && window['localStorage'] !== null;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
saveLocalTemplates: function () {
|
||||
if (this.localStorageAvailable()) {
|
||||
localStorage.setItem("templates", JSON.stringify(this.templates));
|
||||
localStorage.setItem("templateVersion", this.templateVersion);
|
||||
}
|
||||
},
|
||||
|
||||
loadLocalTemplates: function () {
|
||||
if (this.localStorageAvailable()) {
|
||||
var templateVersion = localStorage.getItem("templateVersion");
|
||||
if (templateVersion && templateVersion == this.templateVersion) {
|
||||
var templates = localStorage.getItem("templates");
|
||||
if (templates) {
|
||||
templates = JSON.parse(templates);
|
||||
for (var x in templates) {
|
||||
if (!this.templates[x]) {
|
||||
this.addTemplate(x, templates[x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
localStorage.removeItem("templates");
|
||||
localStorage.removeItem("templateVersion");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
};
|
||||
templateLoader.loadLocalTemplates();
|
||||
window.templateLoader = templateLoader;
|
||||
})();
|
||||
})();
|
||||
|
||||
89
cms/static/js/views/checklists_view.js
Normal file
89
cms/static/js/views/checklists_view.js
Normal file
@@ -0,0 +1,89 @@
|
||||
if (!CMS.Views['Checklists']) CMS.Views.Checklists = {};
|
||||
|
||||
CMS.Views.Checklists = Backbone.View.extend({
|
||||
// takes CMS.Models.Checklists as model
|
||||
|
||||
events : {
|
||||
'click .course-checklist .checklist-title' : "toggleChecklist",
|
||||
'click .course-checklist .task input' : "toggleTask",
|
||||
'click a[rel="external"]' : window.cmsLinkNewWindow
|
||||
},
|
||||
|
||||
initialize : function() {
|
||||
var self = this;
|
||||
|
||||
this.collection.fetch({
|
||||
complete: function () {
|
||||
window.templateLoader.loadRemoteTemplate("checklist",
|
||||
"/static/client_templates/checklist.html",
|
||||
function (raw_template) {
|
||||
self.template = _.template(raw_template);
|
||||
self.render();
|
||||
}
|
||||
);
|
||||
},
|
||||
error: CMS.ServerError
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// catch potential outside call before template loaded
|
||||
if (!this.template) return this;
|
||||
|
||||
this.$el.empty();
|
||||
|
||||
var self = this;
|
||||
_.each(this.collection.models,
|
||||
function(checklist, index) {
|
||||
self.$el.append(self.renderTemplate(checklist, index));
|
||||
});
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
renderTemplate: function (checklist, index) {
|
||||
var checklistItems = checklist.attributes['items'];
|
||||
var itemsChecked = 0;
|
||||
_.each(checklistItems,
|
||||
function(checklist) {
|
||||
if (checklist['is_checked']) {
|
||||
itemsChecked +=1;
|
||||
}
|
||||
});
|
||||
var percentChecked = Math.round((itemsChecked/checklistItems.length)*100);
|
||||
return this.template({
|
||||
checklistIndex : index,
|
||||
checklistShortDescription : checklist.attributes['short_description'],
|
||||
items: checklistItems,
|
||||
itemsChecked: itemsChecked,
|
||||
percentChecked: percentChecked});
|
||||
},
|
||||
|
||||
toggleChecklist : function(e) {
|
||||
e.preventDefault();
|
||||
$(e.target).closest('.course-checklist').toggleClass('is-collapsed');
|
||||
},
|
||||
|
||||
toggleTask : function (e) {
|
||||
var self = this;
|
||||
|
||||
var completed = 'is-completed';
|
||||
var $checkbox = $(e.target);
|
||||
var $task = $checkbox.closest('.task');
|
||||
$task.toggleClass(completed);
|
||||
|
||||
var checklist_index = $checkbox.data('checklist');
|
||||
var task_index = $checkbox.data('task');
|
||||
var model = this.collection.at(checklist_index);
|
||||
model.attributes.items[task_index].is_checked = $task.hasClass(completed);
|
||||
model.save({},
|
||||
{
|
||||
success : function() {
|
||||
var updatedTemplate = self.renderTemplate(model, checklist_index);
|
||||
self.$el.find('#course-checklist'+checklist_index).first().replaceWith(updatedTemplate);
|
||||
},
|
||||
error : CMS.ServerError
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -101,6 +101,12 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
cacheModel.save(fieldName, newVal);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Clear date (note that this clears the time as well, as date and time are linked).
|
||||
// Note also that the validation logic prevents us from clearing the start date
|
||||
// (start date is required by the back end).
|
||||
cacheModel.save(fieldName, null);
|
||||
}
|
||||
};
|
||||
|
||||
// instrument as date and time pickers
|
||||
|
||||
@@ -25,11 +25,7 @@ CMS.Views.ValidatingView = Backbone.View.extend({
|
||||
for (var field in error) {
|
||||
var ele = this.$el.find('#' + this.fieldToSelectorMap[field]);
|
||||
this._cacheValidationErrors.push(ele);
|
||||
if ($(ele).is('div')) {
|
||||
// put error on the contained inputs
|
||||
$(ele).find('input, textarea').addClass('error');
|
||||
}
|
||||
else $(ele).addClass('error');
|
||||
this.getInputElements(ele).addClass('error');
|
||||
$(ele).parent().append(this.errorTemplate({message : error[field]}));
|
||||
}
|
||||
},
|
||||
@@ -37,12 +33,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({
|
||||
clearValidationErrors : function() {
|
||||
// error is object w/ fields and error strings
|
||||
while (this._cacheValidationErrors.length > 0) {
|
||||
var ele = this._cacheValidationErrors.pop();
|
||||
if ($(ele).is('div')) {
|
||||
// put error on the contained inputs
|
||||
$(ele).find('input, textarea').removeClass('error');
|
||||
}
|
||||
else $(ele).removeClass('error');
|
||||
var ele = this._cacheValidationErrors.pop();
|
||||
this.getInputElements(ele).removeClass('error');
|
||||
$(ele).nextAll('.message-error').remove();
|
||||
}
|
||||
},
|
||||
@@ -65,5 +57,16 @@ CMS.Views.ValidatingView = Backbone.View.extend({
|
||||
},
|
||||
inputUnfocus : function(event) {
|
||||
$("label[for='" + event.currentTarget.id + "']").removeClass("is-focused");
|
||||
},
|
||||
|
||||
getInputElements: function(ele) {
|
||||
var inputElements = 'input, textarea';
|
||||
if ($(ele).is(inputElements)) {
|
||||
return $(ele);
|
||||
}
|
||||
else {
|
||||
// put error on the contained inputs
|
||||
return $(ele).find(inputElements);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -274,7 +274,7 @@ p, ul, ol, dl {
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.title, .title-1 {
|
||||
.title-1 {
|
||||
@include font-size(32);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -331,30 +331,38 @@ p, ul, ol, dl {
|
||||
color: $gray-d3;
|
||||
}
|
||||
|
||||
> section {
|
||||
margin: 0 0 ($baseline*2) 0;
|
||||
.title-1 {
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.title-2 {
|
||||
@include font-size(24);
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title-3 {
|
||||
@include font-size(16);
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
header {
|
||||
@include clearfix();
|
||||
|
||||
.title-2 {
|
||||
width: flex-grid(5, 12);
|
||||
margin: 0 flex-gutter() 0 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
header {
|
||||
@include clearfix();
|
||||
|
||||
.title-2 {
|
||||
width: flex-grid(5, 12);
|
||||
margin: 0 flex-gutter() 0 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.tip {
|
||||
@include font-size(13);
|
||||
width: flex-grid(7, 12);
|
||||
float: right;
|
||||
margin-top: ($baseline/2);
|
||||
text-align: right;
|
||||
color: $gray-l2;
|
||||
}
|
||||
.tip {
|
||||
@include font-size(13);
|
||||
width: flex-grid(7, 12);
|
||||
float: right;
|
||||
margin-top: ($baseline/2);
|
||||
text-align: right;
|
||||
color: $gray-l2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,7 +402,8 @@ p, ul, ol, dl {
|
||||
}
|
||||
}
|
||||
|
||||
.nav-related {
|
||||
// navigation
|
||||
.nav-related, .nav-page {
|
||||
|
||||
.nav-item {
|
||||
margin-bottom: ($baseline/4);
|
||||
@@ -710,7 +719,7 @@ hr.divide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 99999;
|
||||
z-index: 10000;
|
||||
padding: 0 10px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
@@ -758,6 +767,10 @@ hr.divide {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
hr.divider {
|
||||
@extend .sr;
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// js dependant
|
||||
|
||||
@@ -22,6 +22,7 @@ $black-t0: rgba(0,0,0,0.125);
|
||||
$black-t1: rgba(0,0,0,0.25);
|
||||
$black-t2: rgba(0,0,0,0.50);
|
||||
$black-t3: rgba(0,0,0,0.75);
|
||||
|
||||
$white: rgb(255,255,255);
|
||||
$white-t0: rgba(255,255,255,0.125);
|
||||
$white-t1: rgba(255,255,255,0.25);
|
||||
@@ -34,6 +35,7 @@ $gray-l2: tint($gray,40%);
|
||||
$gray-l3: tint($gray,60%);
|
||||
$gray-l4: tint($gray,80%);
|
||||
$gray-l5: tint($gray,90%);
|
||||
$gray-l6: tint($gray,95%);
|
||||
$gray-d1: shade($gray,20%);
|
||||
$gray-d2: shade($gray,40%);
|
||||
$gray-d3: shade($gray,60%);
|
||||
@@ -166,4 +168,4 @@ $disabledGreen: rgb(124, 206, 153);
|
||||
$darkGreen: rgb(52, 133, 76);
|
||||
$lightBluishGrey: rgb(197, 207, 223);
|
||||
$lightBluishGrey2: rgb(213, 220, 228);
|
||||
$error-red: rgb(253, 87, 87);
|
||||
$error-red: rgb(253, 87, 87);
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
@import 'elements/modal';
|
||||
@import 'elements/alerts';
|
||||
@import 'elements/jquery-ui-calendar';
|
||||
@import 'elements/tender-widget';
|
||||
|
||||
// specific views
|
||||
@import 'views/account';
|
||||
@@ -44,6 +45,7 @@
|
||||
@import 'views/subsection';
|
||||
@import 'views/unit';
|
||||
@import 'views/users';
|
||||
@import 'views/checklists';
|
||||
|
||||
@import 'assets/content-types';
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
// specific elements - course nav
|
||||
.nav-course {
|
||||
width: 335px;
|
||||
width: 285px;
|
||||
margin-top: -($baseline/4);
|
||||
@include font-size(14);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
font-family: $sans-serif;
|
||||
font-size: 12px;
|
||||
@include box-shadow(0 5px 10px rgba(0, 0, 0, 0.1));
|
||||
z-index: 100000 !important;
|
||||
|
||||
.ui-widget-header {
|
||||
background: $darkGrey;
|
||||
|
||||
267
cms/static/sass/elements/_tender-widget.scss
Normal file
267
cms/static/sass/elements/_tender-widget.scss
Normal file
@@ -0,0 +1,267 @@
|
||||
// tender help/support widget
|
||||
// ====================
|
||||
|
||||
#tender_frame, #tender_window {
|
||||
background-image: none !important;
|
||||
background: none;
|
||||
}
|
||||
|
||||
#tender_window {
|
||||
@include border-radius(3px);
|
||||
@include box-shadow(0 2px 3px $shadow);
|
||||
height: ($baseline*35) !important;
|
||||
background: $white !important;
|
||||
border: 1px solid $gray;
|
||||
}
|
||||
|
||||
#tender_window {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#tender_frame {
|
||||
background: $white;
|
||||
}
|
||||
|
||||
#tender_closer {
|
||||
color: $blue-l2 !important;
|
||||
text-transform: uppercase;
|
||||
|
||||
&:hover {
|
||||
color: $blue-l4 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// tender style overrides - not rendered through here, but an archive is needed
|
||||
#tender_frame iframe html {
|
||||
font-size: 62.5%;
|
||||
}
|
||||
|
||||
.widget-layout {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
|
||||
.widget-layout .search,
|
||||
.widget-layout .tabs,
|
||||
.widget-layout .footer,
|
||||
.widget-layout .header h1 a {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.widget-layout .header {
|
||||
background: rgb(85, 151, 221);
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.widget-layout h1, .widget-layout h2, .widget-layout h3, .widget-layout h4, .widget-layout h5, .widget-layout h6, .widget-layout label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-layout .header h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.widget-layout .content {
|
||||
overflow: auto;
|
||||
height: auto !important;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.widget-layout .flash {
|
||||
margin: -10px 0 15px 0;
|
||||
padding: 10px 20px !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
.widget-layout .flash-error {
|
||||
background: rgb(178, 6, 16) !important;
|
||||
color: rgb(255,255,255) !important;
|
||||
}
|
||||
|
||||
.widget-layout label {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
color: #4c4c4c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.widget-layout input[type="text"], .widget-layout textarea {
|
||||
padding: 10px;
|
||||
font-size: 16px;
|
||||
color: rgb(0,0,0) !important;
|
||||
border: 1px solid #b0b6c2;
|
||||
border-radius: 2px;
|
||||
background-color: #edf1f5;
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #edf1f5),color-stop(100%, #fdfdfe));
|
||||
background-image: -webkit-linear-gradient(top, #edf1f5,#fdfdfe);
|
||||
background-image: -moz-linear-gradient(top, #edf1f5,#fdfdfe);
|
||||
background-image: -ms-linear-gradient(top, #edf1f5,#fdfdfe);
|
||||
background-image: -o-linear-gradient(top, #edf1f5,#fdfdfe);
|
||||
background-image: linear-gradient(top, #edf1f5,#fdfdfe);
|
||||
background-color: #edf1f5;
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.1) inset;
|
||||
-moz-box-shadow: 0 1px 2px rgba(0,0,0,0.1) inset;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1) inset;
|
||||
}
|
||||
|
||||
.widget-layout input[type="text"]:focus, .widget-layout textarea:focus {
|
||||
background-color: #fffcf1;
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #fffcf1),color-stop(100%, #fffefd));
|
||||
background-image: -webkit-linear-gradient(top, #fffcf1,#fffefd);
|
||||
background-image: -moz-linear-gradient(top, #fffcf1,#fffefd);
|
||||
background-image: -ms-linear-gradient(top, #fffcf1,#fffefd);
|
||||
background-image: -o-linear-gradient(top, #fffcf1,#fffefd);
|
||||
background-image: linear-gradient(top, #fffcf1,#fffefd);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.widget-layout textarea {
|
||||
width: 97%;
|
||||
}
|
||||
|
||||
.widget-layout p.note {
|
||||
text-align: right !important;
|
||||
display: inline-block !important;
|
||||
position: absolute !important;
|
||||
right: -130px !important;
|
||||
top: -5px !important;
|
||||
font-size: 13px !important;
|
||||
opacity: 0.80;
|
||||
}
|
||||
|
||||
.widget-layout .form-actions {
|
||||
margin: 15px 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.widget-layout dl.form {
|
||||
float: none;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #f2f2f2;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.widget-layout dl.form:last-child {
|
||||
border: none;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.widget-layout dl.form dt, .widget-layout dl.form dd {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.widget-layout dl.form dt {
|
||||
margin-right: 15px;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.widget-layout dl.form dd {
|
||||
width: 65%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// specific elements
|
||||
.widget-layout #discussion_body {
|
||||
|
||||
}
|
||||
|
||||
.widget-layout #discussion_body:before {
|
||||
content: "What Question or Feedback Would You Like to Share?";
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
color: #4c4c4c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.widget-layout dl#brain_buster_captcha {
|
||||
float: none;
|
||||
width: 100%;
|
||||
border-top: 1px solid #f2f2f2;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.widget-layout dl#brain_buster_captcha dd {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.widget-layout dl#brain_buster_captcha #captcha_answer {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.widget-layout dl#brain_buster_captcha dd label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin: 0 15px 5px 0 !important;
|
||||
}
|
||||
|
||||
.widget-layout dl#brain_buster_captcha dd #captcha_answer {
|
||||
display: block;
|
||||
width: 97%%;
|
||||
}
|
||||
|
||||
.widget-layout .form-actions .btn-post_topic {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto !important;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
-webkit-box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset,0 0 0 rgba(0,0,0,0);
|
||||
-moz-box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset,0 0 0 rgba(0,0,0,0);
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset,0 0 0 rgba(0,0,0,0);
|
||||
-webkit-transition-property: background-color,0.15s;
|
||||
-moz-transition-property: background-color,0.15s;
|
||||
-ms-transition-property: background-color,0.15s;
|
||||
-o-transition-property: background-color,0.15s;
|
||||
transition-property: background-color,0.15s;
|
||||
-webkit-transition-duration: box-shadow,0.15s;
|
||||
-moz-transition-duration: box-shadow,0.15s;
|
||||
-ms-transition-duration: box-shadow,0.15s;
|
||||
-o-transition-duration: box-shadow,0.15s;
|
||||
transition-duration: box-shadow,0.15s;
|
||||
-webkit-transition-timing-function: ease-out;
|
||||
-moz-transition-timing-function: ease-out;
|
||||
-ms-transition-timing-function: ease-out;
|
||||
-o-transition-timing-function: ease-out;
|
||||
transition-timing-function: ease-out;
|
||||
-webkit-transition-delay: 0;
|
||||
-moz-transition-delay: 0;
|
||||
-ms-transition-delay: 0;
|
||||
-o-transition-delay: 0;
|
||||
transition-delay: 0;
|
||||
border: 1px solid #34854c;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(255,255,255,0.3);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(255,255,255,0.3)),color-stop(100%, rgba(255,255,255,0)));
|
||||
background-image: -webkit-linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0));
|
||||
background-image: -moz-linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0));
|
||||
background-image: -ms-linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0));
|
||||
background-image: -o-linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0));
|
||||
background-image: linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0));
|
||||
background-color: #25b85a;
|
||||
-webkit-box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset;
|
||||
-moz-box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.widget-layout .form-actions #private-discussion-opt {
|
||||
float: none;
|
||||
text-align: left;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.widget-layout .form-actions .btn-post_topic:hover, .widget-layout .form-actions .btn-post_topic:active {
|
||||
background-color: #16ca57;
|
||||
color: #fff;
|
||||
}
|
||||
347
cms/static/sass/views/_checklists.scss
Normal file
347
cms/static/sass/views/_checklists.scss
Normal file
@@ -0,0 +1,347 @@
|
||||
// Studio - Course Settings
|
||||
// ====================
|
||||
body.course.checklists {
|
||||
|
||||
.content-primary, .content-supplementary {
|
||||
@include box-sizing(border-box);
|
||||
float: left;
|
||||
}
|
||||
|
||||
.content-primary {
|
||||
width: flex-grid(9, 12);
|
||||
margin-right: flex-gutter();
|
||||
}
|
||||
|
||||
// checklists - general
|
||||
.course-checklist {
|
||||
@extend .window;
|
||||
margin: 0 0 ($baseline*2) 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// visual status
|
||||
.viz-checklist-status {
|
||||
@include text-hide();
|
||||
@include size(100%,($baseline/4));
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: 0;
|
||||
background: $gray-l4;
|
||||
|
||||
.viz-checklist-status-value {
|
||||
@include transition(width 2s ease-in-out .25s);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0%;
|
||||
height: ($baseline/4);
|
||||
background: $green;
|
||||
|
||||
.int {
|
||||
@include text-sr();
|
||||
}
|
||||
}
|
||||
}
|
||||
// <span class="viz viz-checklist-status"><span class="viz value viz-checklist-status-value"><span class="int">0</span>% of checklist completed</span></span>
|
||||
|
||||
// header/title
|
||||
header {
|
||||
@include clearfix();
|
||||
@include box-shadow(inset 0 -1px 1px $shadow-l1);
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid $gray-l3;
|
||||
padding: $baseline ($baseline*1.5);
|
||||
|
||||
.checklist-title {
|
||||
@include transition(color .15s .25s ease-in-out);
|
||||
width: flex-grid(6, 9);
|
||||
margin: 0 flex-gutter() 0 0;
|
||||
float: left;
|
||||
|
||||
.ui-toggle-expansion {
|
||||
@include transition(rotate .15s ease-in-out .25s);
|
||||
@include font-size(14);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/2);
|
||||
color: $gray-l4;
|
||||
}
|
||||
|
||||
&.is-selectable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
|
||||
.ui-toggle-expansion {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checklist-status {
|
||||
@include font-size(13);
|
||||
width: flex-grid(3, 9);
|
||||
float: right;
|
||||
margin-top: ($baseline/2);
|
||||
text-align: right;
|
||||
color: $gray-l2;
|
||||
|
||||
|
||||
.icon-confirm {
|
||||
@include font-size(20);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: ($baseline/2);
|
||||
color: $gray-l4;
|
||||
}
|
||||
|
||||
.status-count {
|
||||
@include font-size(16);
|
||||
margin-left: ($baseline/4);
|
||||
margin-right: ($baseline/4);
|
||||
color: $gray-d3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-amount {
|
||||
@include font-size(16);
|
||||
margin-left: ($baseline/4);
|
||||
color: $gray-d3;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checklist actions
|
||||
.course-checklist-actions {
|
||||
@include clearfix();
|
||||
@include box-shadow(inset 0 1px 1px $shadow-l1);
|
||||
@include transition(border .15s ease-in-out .25s);
|
||||
border-top: 1px solid $gray-l2;
|
||||
padding: $baseline ($baseline*1.5);
|
||||
background: $gray-l4;
|
||||
|
||||
.action-primary {
|
||||
@include green-button();
|
||||
float: left;
|
||||
|
||||
.icon-add {
|
||||
@include font-size(12);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
@include font-size(14);
|
||||
@include grey-button();
|
||||
font-weight: 400;
|
||||
float: right;
|
||||
|
||||
.icon-delete {
|
||||
@include font-size(12);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// state - collapsed
|
||||
&.is-collapsed {
|
||||
|
||||
header {
|
||||
@include box-shadow(none);
|
||||
|
||||
.checklist-title {
|
||||
|
||||
.ui-toggle-expansion {
|
||||
@include transform(rotate(-90deg));
|
||||
@include transform-origin(50% 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-tasks {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// state - completed
|
||||
&.is-completed {
|
||||
|
||||
.viz-checklist-status {
|
||||
|
||||
.viz-checklist-status-value {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
|
||||
.checklist-title, .icon-confirm {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
.checklist-status {
|
||||
|
||||
.status-count, .status-amount, .icon-confirm {
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// state - not available
|
||||
.is-not-available {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// list of tasks
|
||||
.list-tasks {
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
|
||||
.task {
|
||||
@include transition(background .15s ease-in-out .25s);
|
||||
@include transition(border .15s ease-in-out .25s);
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
border-top: 1px solid $white;
|
||||
border-bottom: 1px solid $gray-l5;
|
||||
padding: $baseline ($baseline*1.5);
|
||||
background: $white;
|
||||
opacity: 1.0;
|
||||
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.task-input {
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
float: left;
|
||||
margin: ($baseline/2) flex-gutter() 0 0;
|
||||
}
|
||||
|
||||
.task-details {
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
float: left;
|
||||
width: flex-grid(6,9);
|
||||
font-weight: 500;
|
||||
|
||||
.task-name {
|
||||
@include transition(color .15s .25s ease-in-out);
|
||||
vertical-align: baseline;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
@include transition(color .15s .25s ease-in-out);
|
||||
@include font-size(14);
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.task-support {
|
||||
@include transition(opacity .15s .25s ease-in-out);
|
||||
@include font-size(12);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
@include transition(opacity .15s .25s ease-in-out);
|
||||
@include clearfix();
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
float: right;
|
||||
width: flex-grid(2,9);
|
||||
margin: ($baseline/2) 0 0 flex-gutter();
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-align: right;
|
||||
|
||||
.action-primary {
|
||||
@include blue-button;
|
||||
@include transition(all .15s);
|
||||
@include font-size(12);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
@include font-size(13);
|
||||
margin-top: ($baseline/2);
|
||||
}
|
||||
}
|
||||
|
||||
// state - hover
|
||||
&:hover {
|
||||
background: $blue-l5;
|
||||
border-bottom-color: $blue-l4;
|
||||
border-top-color: $blue-l4;
|
||||
opacity: 1.0;
|
||||
|
||||
.task-details {
|
||||
|
||||
.task-support {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// state - completed
|
||||
&.is-completed {
|
||||
background: $gray-l6;
|
||||
border-top-color: $gray-l5;
|
||||
border-bottom-color: $gray-l5;
|
||||
|
||||
.task-name {
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
|
||||
.action-primary {
|
||||
@include grey-button;
|
||||
@include font-size(12);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $gray-l5;
|
||||
border-bottom-color: $gray-l4;
|
||||
border-top-color: $gray-l4;
|
||||
|
||||
.task-details {
|
||||
opacity:1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-supplementary {
|
||||
width: flex-grid(3, 12);
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,7 @@ body.course.outline {
|
||||
|
||||
.section-published-date {
|
||||
float: right;
|
||||
width: 265px;
|
||||
width: 278px;
|
||||
margin-right: 220px;
|
||||
@include border-radius(3px);
|
||||
background: $lightGrey;
|
||||
|
||||
@@ -172,89 +172,11 @@ body.course.subsection {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.unit-subtitle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sortable-unit-list {
|
||||
ol {
|
||||
@include tree-view;
|
||||
}
|
||||
}
|
||||
|
||||
.policy-list {
|
||||
input[disabled] {
|
||||
border: none;
|
||||
@include box-shadow(none);
|
||||
}
|
||||
|
||||
.policy-list-name {
|
||||
margin-right: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.policy-list-value {
|
||||
width: 320px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.policy-list-element {
|
||||
.save-button,
|
||||
.cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&.editing,
|
||||
&.new-policy-list-element {
|
||||
.policy-list-name,
|
||||
.policy-list-value {
|
||||
border: 1px solid #b0b6c2;
|
||||
@include linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, .3));
|
||||
background-color: #edf1f5;
|
||||
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new-policy-list-element {
|
||||
padding: 10px 10px 0;
|
||||
margin: 0 -10px 10px;
|
||||
border-radius: 3px;
|
||||
background: $mediumGrey;
|
||||
|
||||
.save-button {
|
||||
@include blue-button;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
@include white-button;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.new-policy-item {
|
||||
margin: 10px 0;
|
||||
|
||||
.plus-icon-small {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subsection-name-input {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%inherit file="base.html" />
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="bodyclass">is-signedin course uploads</%block>
|
||||
<%block name="title">Uploads & Files</%block>
|
||||
<%block name="title">Files & Uploads</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<script src="${static.url('js/vendor/jquery.leanModal.min.js')}"></script>
|
||||
<script src="${static.url('js/vendor/jquery.tablednd.js')}"></script>
|
||||
<script src="${static.url('js/vendor/jquery.form.js')}"></script>
|
||||
<script src="${static.url('js/vendor/jquery.smooth-scroll.min.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/htmlmixed.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script>
|
||||
<script type="text/javascript">
|
||||
@@ -59,11 +60,13 @@
|
||||
|
||||
<%block name="content"></%block>
|
||||
<%include file="widgets/footer.html" />
|
||||
<%include file="widgets/tender.html" />
|
||||
<%block name="view_notifications"></%block>
|
||||
</div>
|
||||
|
||||
<%block name="view_prompts"></%block>
|
||||
|
||||
<%block name="jsextra"></%block>
|
||||
</body>
|
||||
|
||||
<%include file="widgets/qualaroo.html" />
|
||||
</html>
|
||||
|
||||
74
cms/templates/checklists.html
Normal file
74
cms/templates/checklists.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<%inherit file="base.html" />
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">Course Checklists</%block>
|
||||
<%block name="bodyclass">is-signedin course uxdesign checklists</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%block name="jsextra">
|
||||
|
||||
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/checklists_view.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/models/checklists.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/server_error.js')}"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
var checklistCollection = new CMS.Models.ChecklistCollection();
|
||||
checklistCollection.url = "${reverse('checklists_updates', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}";
|
||||
|
||||
var editor = new CMS.Views.Checklists({
|
||||
el: $('.course-checklists'),
|
||||
collection: checklistCollection
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
|
||||
<%block name="content">
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions has-subtitle">
|
||||
<div class="title">
|
||||
<span class="title-sub">Tools</span>
|
||||
<h1 class="title-1">Course Checklists</h1>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
<form id="course-checklists" class="course-checklists" method="post" action="">
|
||||
<h2 class="title title-3 sr">Current Checklists</h2>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<div class="bit">
|
||||
<h3 class="title title-3">What are checklists?</h3>
|
||||
<p>
|
||||
Running a course on edX is a complex undertaking. Course checklists are designed to help you understand and keep track of all the steps necessary to get your course ready for students.
|
||||
</p>
|
||||
<p>
|
||||
These checklists are shared among your course team, and any changes you make are immediately visible to other members of the team and saved automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bit">
|
||||
<h3 class="title title-3">Studio checklists</h3>
|
||||
<nav class="nav-page checklists-current">
|
||||
<ol>
|
||||
% for checklist in checklists:
|
||||
<li class="nav-item">
|
||||
<a rel="view" href="${'#course-checklist' + str(loop.index)}">${checklist['short_description']}</a>
|
||||
</li>
|
||||
% endfor
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -2,7 +2,7 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<!-- TODO decode course # from context_course into title -->
|
||||
<%block name="title">Updates</%block>
|
||||
<%block name="title">Course Updates</%block>
|
||||
<%block name="bodyclass">is-signedin course course-info updates</%block>
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
from time import mktime
|
||||
import dateutil.parser
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from xmodule.util.date_utils import get_time_struct_display
|
||||
%>
|
||||
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
@@ -13,7 +11,6 @@
|
||||
|
||||
<%namespace name="units" file="widgets/units.html" />
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%namespace name='datetime' module='datetime'/>
|
||||
|
||||
<%block name="content">
|
||||
<div class="main-wrapper">
|
||||
@@ -31,18 +28,6 @@
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="policy-to-delete" style="display:none">
|
||||
</div>
|
||||
|
||||
<div id="add-new-policy-element-template" style="display:none">
|
||||
<li class="policy-list-element new-policy-list-element">
|
||||
<input type="text" class="policy-list-name" autocomplete="off" size="15"/>: <input type="text" class="policy-list-value" size=40 autocomplete="off"/>
|
||||
<a href="#" class="save-button">Save</a>
|
||||
<a href="#" class="cancel-button">Cancel</a>
|
||||
<a href="#" class="delete-icon remove-policy-data"></a>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="unit-settings window id-holder" data-id="${subsection.location}">
|
||||
<h4 class="header">Subsection Settings</h4>
|
||||
@@ -50,18 +35,15 @@
|
||||
<div class="scheduled-date-input row">
|
||||
<label>Release date:<!-- <span class="description">Determines when this subsection and the units within it will be released publicly.</span>--></label>
|
||||
<div class="datepair" data-language="javascript">
|
||||
<%
|
||||
start_date = datetime.fromtimestamp(mktime(subsection.lms.start)) if subsection.lms.start is not None else None
|
||||
parent_start_date = datetime.fromtimestamp(mktime(parent_item.lms.start)) if parent_item.lms.start is not None else None
|
||||
%>
|
||||
<input type="text" id="start_date" name="start_date" value="${start_date.strftime('%m/%d/%Y') if start_date is not None else ''}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="start_time" name="start_time" value="${start_date.strftime('%H:%M') if start_date is not None else ''}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
<input type="text" id="start_date" name="start_date" value="${get_time_struct_display(subsection.lms.start, '%m/%d/%Y')}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="start_time" name="start_time" value="${get_time_struct_display(subsection.lms.start, '%H:%M')}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
</div>
|
||||
% if subsection.lms.start != parent_item.lms.start and subsection.lms.start:
|
||||
% if parent_start_date is None:
|
||||
% if parent_item.lms.start is None:
|
||||
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset.
|
||||
% else:
|
||||
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default} – ${parent_start_date.strftime('%m/%d/%Y')} at ${parent_start_date.strftime('%H:%M')}.
|
||||
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default} –
|
||||
${get_time_struct_display(parent_item.lms.start, '%m/%d/%Y at %I:%M %p')}.
|
||||
% endif
|
||||
<a href="#" class="sync-date no-spinner">Sync to ${parent_item.display_name_with_default}.</a></p>
|
||||
% endif
|
||||
@@ -78,12 +60,8 @@
|
||||
<a href="#" class="set-date">Set a due date</a>
|
||||
<div class="datepair date-setter">
|
||||
<p class="date-description">
|
||||
<%
|
||||
# due date uses it own formatting for stringifying the date. As with capa_module.py, there's a utility module available for us to use
|
||||
due_date = dateutil.parser.parse(subsection.lms.due) if subsection.lms.due else None
|
||||
%>
|
||||
<input type="text" id="due_date" name="due_date" value="${due_date.strftime('%m/%d/%Y') if due_date is not None else ''}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="due_time" name="due_time" value="${due_date.strftime('%H:%M') if due_date is not None else ''}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
<input type="text" id="due_date" name="due_date" value="${get_time_struct_display(subsection.lms.due, '%m/%d/%Y')}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="due_time" name="due_time" value="${get_time_struct_display(subsection.lms.due, '%H:%M')}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
<a href="#" class="remove-date">Remove due date</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">Export Course</%block>
|
||||
<%block name="title">Course Export</%block>
|
||||
<%block name="bodyclass">is-signedin course tools export</%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<figcaption class="description">Simple two-level outline to organize your couse. Drag and drop, and see your course at a glance.</figcaption>
|
||||
</figure>
|
||||
|
||||
<a href="#" rel="view" class="action action-modal-close">
|
||||
<a href="" rel="view" class="action action-modal-close">
|
||||
<i class="ss-icon ss-symbolicons-block icon icon-close">␡</i>
|
||||
<span class="label">close modal</span>
|
||||
</a>
|
||||
@@ -164,7 +164,7 @@
|
||||
<figcaption class="description">Quickly create videos, text snippets, inline discussions, and a variety of problem types.</figcaption>
|
||||
</figure>
|
||||
|
||||
<a href="#" rel="view" class="action action-modal-close">
|
||||
<a href="" rel="view" class="action action-modal-close">
|
||||
<i class="ss-icon ss-symbolicons-block icon icon-close">␡</i>
|
||||
<span class="label">close modal</span>
|
||||
</a>
|
||||
@@ -177,7 +177,7 @@
|
||||
<figcaption class="description">Simply set the date of a section or subsection, and Studio will publish it to your students for you.</figcaption>
|
||||
</figure>
|
||||
|
||||
<a href="#" rel="view" class="action action-modal-close">
|
||||
<a href="" rel="view" class="action action-modal-close">
|
||||
<i class="ss-icon ss-symbolicons-block icon icon-close">␡</i>
|
||||
<span class="label">close modal</span>
|
||||
</a>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">Import Course</%block>
|
||||
<%block name="title">Course Import</%block>
|
||||
<%block name="bodyclass">is-signedin course tools import</%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%inherit file="base.html" />
|
||||
|
||||
<%block name="title">Courses</%block>
|
||||
<%block name="title">My Courses</%block>
|
||||
<%block name="bodyclass">is-signedin index dashboard</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%inherit file="base.html" />
|
||||
<%block name="title">Course Staff Manager</%block>
|
||||
<%block name="title">Course Team Settings</%block>
|
||||
<%block name="bodyclass">is-signedin course users settings team</%block>
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
from time import mktime
|
||||
import dateutil.parser
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from xmodule.util.date_utils import get_time_struct_display
|
||||
%>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">Course Outline</%block>
|
||||
@@ -163,11 +161,10 @@
|
||||
</h3>
|
||||
<div class="section-published-date">
|
||||
<%
|
||||
start_date = datetime.fromtimestamp(mktime(section.lms.start)) if section.lms.start is not None else None
|
||||
start_date_str = start_date.strftime('%m/%d/%Y') if start_date is not None else ''
|
||||
start_time_str = start_date.strftime('%H:%M') if start_date is not None else ''
|
||||
start_date_str = get_time_struct_display(section.lms.start, '%m/%d/%Y')
|
||||
start_time_str = get_time_struct_display(section.lms.start, '%I:%M %p')
|
||||
%>
|
||||
%if start_date is None:
|
||||
%if section.lms.start is None:
|
||||
<span class="published-status">This section has not been released.</span>
|
||||
<a href="#" class="schedule-button" data-date="" data-time="" data-id="${section.location}">Schedule</a>
|
||||
%else:
|
||||
@@ -200,7 +197,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="gradable-status" data-initial-status="${subsection.lms.format if section.lms.format is not None else 'Not Graded'}">
|
||||
<div class="gradable-status" data-initial-status="${subsection.lms.format if subsection.lms.format is not None else 'Not Graded'}">
|
||||
</div>
|
||||
|
||||
<div class="item-actions">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%inherit file="base.html" />
|
||||
<%block name="title">Schedule & Details</%block>
|
||||
<%block name="title">Schedule & Details Settings</%block>
|
||||
<%block name="bodyclass">is-signedin course schedule settings</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%inherit file="base.html" />
|
||||
<%block name="title">Grading</%block>
|
||||
<%block name="title">Grading Settings</%block>
|
||||
<%block name="bodyclass">is-signedin course grading settings</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
This unit was originally published on ${published_date}.
|
||||
% endif
|
||||
</p>
|
||||
<a href="${published_preview_link}" target="_blank" class="alert-action secondary">Preview the published version</a>
|
||||
<a href="${published_preview_link}" target="_blank" class="alert-action secondary">View the Live Version</a>
|
||||
</div>
|
||||
<div class="main-column">
|
||||
<article class="unit-body window">
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
<li class="nav-item nav-peripheral-pp">
|
||||
<a href="#">Privacy Policy</a>
|
||||
</li> -->
|
||||
|
||||
<li class="nav-item nav-peripheral-help">
|
||||
<a href="http://help.edge.edx.org/" rel="external">edX Studio Help</a>
|
||||
</li>
|
||||
<li class="nav-item nav-peripheral-contact">
|
||||
<a href="https://www.edx.org/contact" rel="external">Contact edX</a>
|
||||
</li>
|
||||
|
||||
% if user.is_authenticated():
|
||||
<!-- add in zendesk/tender feedback form UI -->
|
||||
<li class="nav-item nav-peripheral-feedback">
|
||||
<a class="show-tender" href="http://help.edge.edx.org/discussion/new" title="Use our feedback tool, Tender, to share your feedback">Contact Us</a>
|
||||
</li>
|
||||
% endif
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
|
||||
<div class="wrapper-header wrapper">
|
||||
<div class="wrapper-header wrapper" id="view-top">
|
||||
<header class="primary" role="banner">
|
||||
|
||||
<div class="wrapper wrapper-left ">
|
||||
@@ -56,6 +56,7 @@
|
||||
<div class="wrapper wrapper-nav-sub">
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<li class="nav-item nav-course-tools-checklists"><a href="${reverse('checklists', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Checklists</a></li>
|
||||
<li class="nav-item nav-course-tools-import"><a href="${reverse('import_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Import</a></li>
|
||||
<li class="nav-item nav-course-tools-export"><a href="${reverse('export_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Export</a></li>
|
||||
</ul>
|
||||
|
||||
13
cms/templates/widgets/qualaroo.html
Normal file
13
cms/templates/widgets/qualaroo.html
Normal file
@@ -0,0 +1,13 @@
|
||||
% if settings.MITX_FEATURES.get('STUDIO_NPS_SURVEY'):
|
||||
<!-- Qualaroo is used for net promoter score surveys -->
|
||||
<script type="text/javascript">
|
||||
% if user.is_authenticated():
|
||||
var _kiq = _kiq || [];
|
||||
_kiq.push(['identify', "${ user.email }" ]);
|
||||
% endif
|
||||
</script>
|
||||
|
||||
<!-- Qualaroo for edx.org -->
|
||||
<script type="text/javascript" src="//s3.amazonaws.com/ki.js/48221/9SN.js" async="true"></script>
|
||||
<!-- end Qualaroo -->
|
||||
% endif
|
||||
13
cms/templates/widgets/tender.html
Normal file
13
cms/templates/widgets/tender.html
Normal file
@@ -0,0 +1,13 @@
|
||||
% if user.is_authenticated():
|
||||
|
||||
<script type="text/javascript">
|
||||
Tender = {
|
||||
hideToggle: true,
|
||||
title: '',
|
||||
body: '',
|
||||
hide_kb: 'true',
|
||||
widgetToggles: $('.show-tender')
|
||||
}
|
||||
</script>
|
||||
<script src="https://edxedge.tenderapp.com/tender_widget.js" type="text/javascript"></script>
|
||||
% endif
|
||||
51
cms/urls.py
51
cms/urls.py
@@ -42,36 +42,52 @@ urlpatterns = ('',
|
||||
'contentstore.views.remove_user', name='remove_user'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<name>[^/]+)/remove_user$',
|
||||
'contentstore.views.remove_user', name='remove_user'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/info/(?P<name>[^/]+)$', 'contentstore.views.course_info', name='course_info'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course_info/updates/(?P<provided_id>.*)$', 'contentstore.views.course_info_updates', name='course_info_json'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$', 'contentstore.views.course_config_graders_page', name='course_settings'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)/section/(?P<section>[^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)/(?P<grader_index>.*)$', 'contentstore.views.course_grader_updates', name='course_settings'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/info/(?P<name>[^/]+)$',
|
||||
'contentstore.views.course_info', name='course_info'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course_info/updates/(?P<provided_id>.*)$',
|
||||
'contentstore.views.course_info_updates', name='course_info_json'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)$',
|
||||
'contentstore.views.get_course_settings', name='settings_details'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$',
|
||||
'contentstore.views.course_config_graders_page', name='settings_grading'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)/section/(?P<section>[^/]+).*$',
|
||||
'contentstore.views.course_settings_updates', name='course_settings'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)/(?P<grader_index>.*)$',
|
||||
'contentstore.views.course_grader_updates', name='course_settings'),
|
||||
# This is the URL to initially render the course advanced settings.
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)$', 'contentstore.views.course_config_advanced_page', name='course_advanced_settings'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)$',
|
||||
'contentstore.views.course_config_advanced_page', name='course_advanced_settings'),
|
||||
# This is the URL used by BackBone for updating and re-fetching the model.
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)/update.*$', 'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)/update.*$',
|
||||
'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'),
|
||||
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/(?P<category>[^/]+)/(?P<name>[^/]+)/gradeas.*$', 'contentstore.views.assignment_type_update', name='assignment_type_update'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/(?P<category>[^/]+)/(?P<name>[^/]+)/gradeas.*$',
|
||||
'contentstore.views.assignment_type_update', name='assignment_type_update'),
|
||||
|
||||
url(r'^pages/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.static_pages',
|
||||
url(r'^pages/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
|
||||
'contentstore.views.static_pages',
|
||||
name='static_pages'),
|
||||
url(r'^edit_static/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_static', name='edit_static'),
|
||||
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$', 'contentstore.views.asset_index', name='asset_index'),
|
||||
url(r'^edit_static/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
|
||||
'contentstore.views.edit_static', name='edit_static'),
|
||||
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
|
||||
'contentstore.views.edit_tabs', name='edit_tabs'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$',
|
||||
'contentstore.views.asset_index', name='asset_index'),
|
||||
|
||||
# this is a generic method to return the data/metadata associated with a xmodule
|
||||
url(r'^module_info/(?P<module_location>.*)$', 'contentstore.views.module_info', name='module_info'),
|
||||
url(r'^module_info/(?P<module_location>.*)$',
|
||||
'contentstore.views.module_info', name='module_info'),
|
||||
|
||||
|
||||
# temporary landing page for a course
|
||||
url(r'^edge/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.landing', name='landing'),
|
||||
url(r'^edge/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
|
||||
'contentstore.views.landing', name='landing'),
|
||||
|
||||
url(r'^not_found$', 'contentstore.views.not_found', name='not_found'),
|
||||
url(r'^server_error$', 'contentstore.views.server_error', name='server_error'),
|
||||
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$', 'contentstore.views.asset_index', name='asset_index'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$',
|
||||
'contentstore.views.asset_index', name='asset_index'),
|
||||
|
||||
# temporary landing page for edge
|
||||
url(r'^edge$', 'contentstore.views.edge', name='edge'),
|
||||
@@ -84,6 +100,9 @@ urlpatterns = ('',
|
||||
# User creation and updating views
|
||||
urlpatterns += (
|
||||
url(r'^ux-alerts$', 'contentstore.views.ux_alerts', name='ux-alerts'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/checklists/(?P<name>[^/]+)$', 'contentstore.views.get_checklists', name='checklists'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/checklists/(?P<name>[^/]+)/update(/)?(?P<checklist_index>.+)?.*$',
|
||||
'contentstore.views.update_checklist', name='checklists_updates'),
|
||||
url(r'^howitworks$', 'contentstore.views.howitworks', name='howitworks'),
|
||||
url(r'^signup$', 'contentstore.views.signup', name='signup'),
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ class CmsNamespace(Namespace):
|
||||
"""
|
||||
Namespace with fields common to all blocks in Studio
|
||||
"""
|
||||
is_draft = Boolean(help="Whether this module is a draft", default=False, scope=Scope.settings)
|
||||
published_date = DateTuple(help="Date when the module was published", scope=Scope.settings)
|
||||
published_by = String(help="Id of the user who published this module", scope=Scope.settings)
|
||||
empty = StringyBoolean(help="Whether this is an empty template", scope=Scope.settings, default=False)
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.http import HttpResponse, Http404, HttpResponseNotModified
|
||||
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.contentstore.content import StaticContent, XASSET_LOCATION_TAG
|
||||
from xmodule.modulestore import InvalidLocationError
|
||||
from cache_toolbox.core import get_cached_content, set_cached_content
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
@@ -13,7 +14,14 @@ class StaticContentServer(object):
|
||||
def process_request(self, request):
|
||||
# look to see if the request is prefixed with 'c4x' tag
|
||||
if request.path.startswith('/' + XASSET_LOCATION_TAG + '/'):
|
||||
loc = StaticContent.get_location_from_path(request.path)
|
||||
try:
|
||||
loc = StaticContent.get_location_from_path(request.path)
|
||||
except InvalidLocationError:
|
||||
# return a 'Bad Request' to browser as we have a malformed Location
|
||||
response = HttpResponse()
|
||||
response.status_code = 400
|
||||
return response
|
||||
|
||||
# first look in our cache so we don't have to round-trip to the DB
|
||||
content = get_cached_content(loc)
|
||||
if content is None:
|
||||
|
||||
0
common/djangoapps/request_cache/__init__.py
Normal file
0
common/djangoapps/request_cache/__init__.py
Normal file
20
common/djangoapps/request_cache/middleware.py
Normal file
20
common/djangoapps/request_cache/middleware.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import threading
|
||||
|
||||
_request_cache_threadlocal = threading.local()
|
||||
_request_cache_threadlocal.data = {}
|
||||
|
||||
class RequestCache(object):
|
||||
@classmethod
|
||||
def get_request_cache(cls):
|
||||
return _request_cache_threadlocal
|
||||
|
||||
def clear_request_cache(self):
|
||||
_request_cache_threadlocal.data = {}
|
||||
|
||||
def process_request(self, request):
|
||||
self.clear_request_cache()
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
self.clear_request_cache()
|
||||
return response
|
||||
@@ -75,10 +75,15 @@ class UserProfile(models.Model):
|
||||
GENDER_CHOICES = (('m', 'Male'), ('f', 'Female'), ('o', 'Other'))
|
||||
gender = models.CharField(blank=True, null=True, max_length=6, db_index=True,
|
||||
choices=GENDER_CHOICES)
|
||||
LEVEL_OF_EDUCATION_CHOICES = (('p_se', 'Doctorate in science or engineering'),
|
||||
('p_oth', 'Doctorate in another field'),
|
||||
|
||||
# [03/21/2013] removed these, but leaving comment since there'll still be
|
||||
# p_se and p_oth in the existing data in db.
|
||||
# ('p_se', 'Doctorate in science or engineering'),
|
||||
# ('p_oth', 'Doctorate in another field'),
|
||||
LEVEL_OF_EDUCATION_CHOICES = (('p', 'Doctorate'),
|
||||
('m', "Master's or professional degree"),
|
||||
('b', "Bachelor's degree"),
|
||||
('a', "Associate's degree"),
|
||||
('hs', "Secondary/high school"),
|
||||
('jhs', "Junior secondary/junior high/middle school"),
|
||||
('el', "Elementary/primary school"),
|
||||
|
||||
@@ -311,7 +311,7 @@ def change_enrollment(request):
|
||||
course = course_from_id(course_id)
|
||||
except ItemNotFoundError:
|
||||
log.warning("User {0} tried to enroll in non-existent course {1}"
|
||||
.format(user.username, enrollment.course_id))
|
||||
.format(user.username, course_id))
|
||||
return {'success': False, 'error': 'The course requested does not exist.'}
|
||||
|
||||
if not has_access(user, course, 'enroll'):
|
||||
@@ -325,7 +325,12 @@ def change_enrollment(request):
|
||||
"course:{0}".format(course_num),
|
||||
"run:{0}".format(run)])
|
||||
|
||||
enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
|
||||
try:
|
||||
enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
|
||||
except IntegrityError:
|
||||
# If we've already created this enrollment in a separate transaction,
|
||||
# then just continue
|
||||
pass
|
||||
return {'success': True}
|
||||
|
||||
elif action == "unenroll":
|
||||
@@ -369,14 +374,14 @@ def login_user(request, error=""):
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
log.warning("Login failed - Unknown user email: {0}".format(email))
|
||||
log.warning(u"Login failed - Unknown user email: {0}".format(email))
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'value': 'Email or password is incorrect.'})) # TODO: User error message
|
||||
|
||||
username = user.username
|
||||
user = authenticate(username=username, password=password)
|
||||
if user is None:
|
||||
log.warning("Login failed - password for {0} is invalid".format(email))
|
||||
log.warning(u"Login failed - password for {0} is invalid".format(email))
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'value': 'Email or password is incorrect.'}))
|
||||
|
||||
@@ -392,7 +397,7 @@ def login_user(request, error=""):
|
||||
log.critical("Login failed - Could not create session. Is memcached running?")
|
||||
log.exception(e)
|
||||
|
||||
log.info("Login success - {0} ({1})".format(username, email))
|
||||
log.info(u"Login success - {0} ({1})".format(username, email))
|
||||
|
||||
try_change_enrollment(request)
|
||||
|
||||
@@ -400,7 +405,7 @@ def login_user(request, error=""):
|
||||
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
|
||||
log.warning("Login failed - Account not active for user {0}, resending activation".format(username))
|
||||
log.warning(u"Login failed - Account not active for user {0}, resending activation".format(username))
|
||||
|
||||
reactivation_email_for_user(user)
|
||||
not_activated_msg = "This account has not been activated. We have " + \
|
||||
|
||||
140
common/djangoapps/terrain/course_helpers.py
Normal file
140
common/djangoapps/terrain/course_helpers.py
Normal file
@@ -0,0 +1,140 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from .factories import *
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.contrib.auth.middleware import AuthenticationMiddleware
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from student.models import CourseEnrollment
|
||||
from xmodule.modulestore.django import _MODULESTORES, modulestore
|
||||
from xmodule.templates import update_templates
|
||||
from bs4 import BeautifulSoup
|
||||
import os.path
|
||||
from urllib import quote_plus
|
||||
from lettuce.django import django_url
|
||||
|
||||
|
||||
@world.absorb
|
||||
def create_user(uname):
|
||||
|
||||
# If the user already exists, don't try to create it again
|
||||
if len(User.objects.filter(username=uname)) > 0:
|
||||
return
|
||||
|
||||
portal_user = UserFactory.build(username=uname, email=uname + '@edx.org')
|
||||
portal_user.set_password('test')
|
||||
portal_user.save()
|
||||
|
||||
registration = world.RegistrationFactory(user=portal_user)
|
||||
registration.register(portal_user)
|
||||
registration.activate()
|
||||
|
||||
user_profile = world.UserProfileFactory(user=portal_user)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def log_in(username, password):
|
||||
'''
|
||||
Log the user in programatically
|
||||
'''
|
||||
|
||||
# Authenticate the user
|
||||
user = authenticate(username=username, password=password)
|
||||
assert(user is not None and user.is_active)
|
||||
|
||||
# Send a fake HttpRequest to log the user in
|
||||
# We need to process the request using
|
||||
# Session middleware and Authentication middleware
|
||||
# to ensure that session state can be stored
|
||||
request = HttpRequest()
|
||||
SessionMiddleware().process_request(request)
|
||||
AuthenticationMiddleware().process_request(request)
|
||||
login(request, user)
|
||||
|
||||
# Save the session
|
||||
request.session.save()
|
||||
|
||||
# Retrieve the sessionid and add it to the browser's cookies
|
||||
cookie_dict = {settings.SESSION_COOKIE_NAME: request.session.session_key}
|
||||
try:
|
||||
world.browser.cookies.add(cookie_dict)
|
||||
|
||||
# WebDriver has an issue where we cannot set cookies
|
||||
# before we make a GET request, so if we get an error,
|
||||
# we load the '/' page and try again
|
||||
except:
|
||||
world.browser.visit(django_url('/'))
|
||||
world.browser.cookies.add(cookie_dict)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def register_by_course_id(course_id, is_staff=False):
|
||||
create_user('robot')
|
||||
u = User.objects.get(username='robot')
|
||||
if is_staff:
|
||||
u.is_staff = True
|
||||
u.save()
|
||||
CourseEnrollment.objects.get_or_create(user=u, course_id=course_id)
|
||||
|
||||
|
||||
|
||||
@world.absorb
|
||||
def save_the_course_content(path='/tmp'):
|
||||
html = world.browser.html.encode('ascii', 'ignore')
|
||||
soup = BeautifulSoup(html)
|
||||
|
||||
# get rid of the header, we only want to compare the body
|
||||
soup.head.decompose()
|
||||
|
||||
# for now, remove the data-id attributes, because they are
|
||||
# causing mismatches between cms-master and master
|
||||
for item in soup.find_all(attrs={'data-id': re.compile('.*')}):
|
||||
del item['data-id']
|
||||
|
||||
# we also need to remove them from unrendered problems,
|
||||
# where they are contained in the text of divs instead of
|
||||
# in attributes of tags
|
||||
# Be careful of whether or not it was the last attribute
|
||||
# and needs a trailing space
|
||||
for item in soup.find_all(text=re.compile(' data-id=".*?" ')):
|
||||
s = unicode(item.string)
|
||||
item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s))
|
||||
|
||||
for item in soup.find_all(text=re.compile(' data-id=".*?"')):
|
||||
s = unicode(item.string)
|
||||
item.string.replace_with(re.sub(' data-id=".*?"', ' ', s))
|
||||
|
||||
# prettify the html so it will compare better, with
|
||||
# each HTML tag on its own line
|
||||
output = soup.prettify()
|
||||
|
||||
# use string slicing to grab everything after 'courseware/' in the URL
|
||||
u = world.browser.url
|
||||
section_url = u[u.find('courseware/') + 11:]
|
||||
|
||||
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path)
|
||||
|
||||
filename = '%s.html' % (quote_plus(section_url))
|
||||
f = open('%s/%s' % (path, filename), 'w')
|
||||
f.write(output)
|
||||
f.close
|
||||
|
||||
|
||||
@world.absorb
|
||||
def clear_courses():
|
||||
# Flush and initialize the module store
|
||||
# It needs the templates because it creates new records
|
||||
# by cloning from the template.
|
||||
# Note that if your test module gets in some weird state
|
||||
# (though it shouldn't), do this manually
|
||||
# from the bash shell to drop it:
|
||||
# $ mongo test_xmodule --eval "db.dropDatabase()"
|
||||
_MODULESTORES = {}
|
||||
modulestore().collection.drop()
|
||||
update_templates()
|
||||
@@ -1,20 +1,21 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
# Disable the "wildcard import" warning so we can bring in all methods from
|
||||
# course helpers and ui helpers
|
||||
#pylint: disable=W0401
|
||||
|
||||
# Disable the "Unused import %s from wildcard import" warning
|
||||
#pylint: disable=W0614
|
||||
|
||||
# Disable the "unused argument" warning because lettuce uses "step"
|
||||
#pylint: disable=W0613
|
||||
|
||||
from lettuce import world, step
|
||||
from .factories import *
|
||||
from .course_helpers import *
|
||||
from .ui_helpers import *
|
||||
from lettuce.django import django_url
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.contrib.auth.middleware import AuthenticationMiddleware
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from student.models import CourseEnrollment
|
||||
from urllib import quote_plus
|
||||
from nose.tools import assert_equals
|
||||
from bs4 import BeautifulSoup
|
||||
import time
|
||||
import re
|
||||
import os.path
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from nose.tools import assert_equals, assert_in
|
||||
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
@@ -22,7 +23,7 @@ logger = getLogger(__name__)
|
||||
|
||||
@step(u'I wait (?:for )?"(\d+)" seconds?$')
|
||||
def wait(step, seconds):
|
||||
time.sleep(float(seconds))
|
||||
world.wait(seconds)
|
||||
|
||||
|
||||
@step('I reload the page$')
|
||||
@@ -30,44 +31,49 @@ def reload_the_page(step):
|
||||
world.browser.reload()
|
||||
|
||||
|
||||
@step('I press the browser back button$')
|
||||
def browser_back(step):
|
||||
world.browser.driver.back()
|
||||
|
||||
|
||||
@step('I (?:visit|access|open) the homepage$')
|
||||
def i_visit_the_homepage(step):
|
||||
world.browser.visit(django_url('/'))
|
||||
assert world.browser.is_element_present_by_css('header.global', 10)
|
||||
world.visit('/')
|
||||
assert world.is_css_present('header.global')
|
||||
|
||||
|
||||
@step(u'I (?:visit|access|open) the dashboard$')
|
||||
def i_visit_the_dashboard(step):
|
||||
world.browser.visit(django_url('/dashboard'))
|
||||
assert world.browser.is_element_present_by_css('section.container.dashboard', 5)
|
||||
world.visit('/dashboard')
|
||||
assert world.is_css_present('section.container.dashboard')
|
||||
|
||||
|
||||
@step('I should be on the dashboard page$')
|
||||
def i_should_be_on_the_dashboard(step):
|
||||
assert world.browser.is_element_present_by_css('section.container.dashboard', 5)
|
||||
assert world.is_css_present('section.container.dashboard')
|
||||
assert world.browser.title == 'Dashboard'
|
||||
|
||||
|
||||
@step(u'I (?:visit|access|open) the courses page$')
|
||||
def i_am_on_the_courses_page(step):
|
||||
world.browser.visit(django_url('/courses'))
|
||||
assert world.browser.is_element_present_by_css('section.courses')
|
||||
world.visit('/courses')
|
||||
assert world.is_css_present('section.courses')
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" button$')
|
||||
def and_i_press_the_button(step, value):
|
||||
button_css = 'input[value="%s"]' % value
|
||||
world.browser.find_by_css(button_css).first.click()
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I click the link with the text "([^"]*)"$')
|
||||
def click_the_link_with_the_text_group1(step, linktext):
|
||||
world.browser.find_link_by_text(linktext).first.click()
|
||||
world.click_link(linktext)
|
||||
|
||||
|
||||
@step('I should see that the path is "([^"]*)"$')
|
||||
def i_should_see_that_the_path_is(step, path):
|
||||
assert world.browser.url == django_url(path)
|
||||
assert world.url_equals(path)
|
||||
|
||||
|
||||
@step(u'the page title should be "([^"]*)"$')
|
||||
@@ -80,10 +86,15 @@ def the_page_title_should_contain(step, title):
|
||||
assert(title in world.browser.title)
|
||||
|
||||
|
||||
@step('I log in$')
|
||||
def i_log_in(step):
|
||||
world.log_in('robot', 'test')
|
||||
|
||||
|
||||
@step('I am a logged in user$')
|
||||
def i_am_logged_in_user(step):
|
||||
create_user('robot')
|
||||
log_in('robot', 'test')
|
||||
world.create_user('robot')
|
||||
world.log_in('robot', 'test')
|
||||
|
||||
|
||||
@step('I am not logged in$')
|
||||
@@ -93,151 +104,41 @@ def i_am_not_logged_in(step):
|
||||
|
||||
@step('I am staff for course "([^"]*)"$')
|
||||
def i_am_staff_for_course_by_id(step, course_id):
|
||||
register_by_course_id(course_id, True)
|
||||
world.register_by_course_id(course_id, True)
|
||||
|
||||
|
||||
@step('I log in$')
|
||||
def i_log_in(step):
|
||||
log_in('robot', 'test')
|
||||
@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$')
|
||||
def click_the_link_called(step, text):
|
||||
world.click_link(text)
|
||||
|
||||
|
||||
@step(r'should see that the url is "([^"]*)"$')
|
||||
def should_have_the_url(step, url):
|
||||
assert_equals(world.browser.url, url)
|
||||
|
||||
|
||||
@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$')
|
||||
def should_see_a_link_called(step, text):
|
||||
assert len(world.browser.find_link_by_text(text)) > 0
|
||||
|
||||
|
||||
@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page')
|
||||
def should_see_in_the_page(step, text):
|
||||
assert_in(text, world.css_text('body'))
|
||||
|
||||
|
||||
@step('I am logged in$')
|
||||
def i_am_logged_in(step):
|
||||
world.create_user('robot')
|
||||
world.log_in('robot', 'test')
|
||||
world.browser.visit(django_url('/'))
|
||||
|
||||
|
||||
@step(u'I am an edX user$')
|
||||
def i_am_an_edx_user(step):
|
||||
create_user('robot')
|
||||
|
||||
#### helper functions
|
||||
world.create_user('robot')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def scroll_to_bottom():
|
||||
# Maximize the browser
|
||||
world.browser.execute_script("window.scrollTo(0, screen.height);")
|
||||
|
||||
|
||||
@world.absorb
|
||||
def create_user(uname):
|
||||
|
||||
# If the user already exists, don't try to create it again
|
||||
if len(User.objects.filter(username=uname)) > 0:
|
||||
return
|
||||
|
||||
portal_user = UserFactory.build(username=uname, email=uname + '@edx.org')
|
||||
portal_user.set_password('test')
|
||||
portal_user.save()
|
||||
|
||||
registration = world.RegistrationFactory(user=portal_user)
|
||||
registration.register(portal_user)
|
||||
registration.activate()
|
||||
|
||||
user_profile = world.UserProfileFactory(user=portal_user)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def log_in(username, password):
|
||||
'''
|
||||
Log the user in programatically
|
||||
'''
|
||||
|
||||
# Authenticate the user
|
||||
user = authenticate(username=username, password=password)
|
||||
assert(user is not None and user.is_active)
|
||||
|
||||
# Send a fake HttpRequest to log the user in
|
||||
# We need to process the request using
|
||||
# Session middleware and Authentication middleware
|
||||
# to ensure that session state can be stored
|
||||
request = HttpRequest()
|
||||
SessionMiddleware().process_request(request)
|
||||
AuthenticationMiddleware().process_request(request)
|
||||
login(request, user)
|
||||
|
||||
# Save the session
|
||||
request.session.save()
|
||||
|
||||
# Retrieve the sessionid and add it to the browser's cookies
|
||||
cookie_dict = {settings.SESSION_COOKIE_NAME: request.session.session_key}
|
||||
try:
|
||||
world.browser.cookies.add(cookie_dict)
|
||||
|
||||
# WebDriver has an issue where we cannot set cookies
|
||||
# before we make a GET request, so if we get an error,
|
||||
# we load the '/' page and try again
|
||||
except:
|
||||
world.browser.visit(django_url('/'))
|
||||
world.browser.cookies.add(cookie_dict)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def register_by_course_id(course_id, is_staff=False):
|
||||
create_user('robot')
|
||||
u = User.objects.get(username='robot')
|
||||
if is_staff:
|
||||
u.is_staff = True
|
||||
u.save()
|
||||
CourseEnrollment.objects.get_or_create(user=u, course_id=course_id)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def save_the_html(path='/tmp'):
|
||||
u = world.browser.url
|
||||
html = world.browser.html.encode('ascii', 'ignore')
|
||||
filename = '%s.html' % quote_plus(u)
|
||||
f = open('%s/%s' % (path, filename), 'w')
|
||||
f.write(html)
|
||||
f.close
|
||||
|
||||
|
||||
@world.absorb
|
||||
def save_the_course_content(path='/tmp'):
|
||||
html = world.browser.html.encode('ascii', 'ignore')
|
||||
soup = BeautifulSoup(html)
|
||||
|
||||
# get rid of the header, we only want to compare the body
|
||||
soup.head.decompose()
|
||||
|
||||
# for now, remove the data-id attributes, because they are
|
||||
# causing mismatches between cms-master and master
|
||||
for item in soup.find_all(attrs={'data-id': re.compile('.*')}):
|
||||
del item['data-id']
|
||||
|
||||
# we also need to remove them from unrendered problems,
|
||||
# where they are contained in the text of divs instead of
|
||||
# in attributes of tags
|
||||
# Be careful of whether or not it was the last attribute
|
||||
# and needs a trailing space
|
||||
for item in soup.find_all(text=re.compile(' data-id=".*?" ')):
|
||||
s = unicode(item.string)
|
||||
item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s))
|
||||
|
||||
for item in soup.find_all(text=re.compile(' data-id=".*?"')):
|
||||
s = unicode(item.string)
|
||||
item.string.replace_with(re.sub(' data-id=".*?"', ' ', s))
|
||||
|
||||
# prettify the html so it will compare better, with
|
||||
# each HTML tag on its own line
|
||||
output = soup.prettify()
|
||||
|
||||
# use string slicing to grab everything after 'courseware/' in the URL
|
||||
u = world.browser.url
|
||||
section_url = u[u.find('courseware/') + 11:]
|
||||
|
||||
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path)
|
||||
|
||||
filename = '%s.html' % (quote_plus(section_url))
|
||||
f = open('%s/%s' % (path, filename), 'w')
|
||||
f.write(output)
|
||||
f.close
|
||||
|
||||
@world.absorb
|
||||
def css_click(css_selector):
|
||||
try:
|
||||
world.browser.find_by_css(css_selector).click()
|
||||
|
||||
except WebDriverException:
|
||||
# Occassionally, MathJax or other JavaScript can cover up
|
||||
# an element temporarily.
|
||||
# If this happens, wait a second, then try again
|
||||
time.sleep(1)
|
||||
world.browser.find_by_css(css_selector).click()
|
||||
@step(u'User "([^"]*)" is an edX user$')
|
||||
def registered_edx_user(step, uname):
|
||||
world.create_user(uname)
|
||||
|
||||
117
common/djangoapps/terrain/ui_helpers.py
Normal file
117
common/djangoapps/terrain/ui_helpers.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
import time
|
||||
from urllib import quote_plus
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from lettuce.django import django_url
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait(seconds):
|
||||
time.sleep(float(seconds))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for(func):
|
||||
WebDriverWait(world.browser.driver, 5).until(func)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def visit(url):
|
||||
world.browser.visit(django_url(url))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def url_equals(url):
|
||||
return world.browser.url == django_url(url)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def is_css_present(css_selector):
|
||||
return world.browser.is_element_present_by_css(css_selector, wait_time=4)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_has_text(css_selector, text):
|
||||
return world.css_text(css_selector) == text
|
||||
|
||||
|
||||
@world.absorb
|
||||
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)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_click(css_selector):
|
||||
'''
|
||||
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:
|
||||
world.browser.find_by_css(css_selector).click()
|
||||
|
||||
except WebDriverException:
|
||||
# Occassionally, MathJax or other JavaScript can cover up
|
||||
# an element temporarily.
|
||||
# If this happens, wait a second, then try again
|
||||
time.sleep(1)
|
||||
world.browser.find_by_css(css_selector).click()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_click_at(css, x=10, y=10):
|
||||
'''
|
||||
A method to click at x,y coordinates of the element
|
||||
rather than in the center of the element
|
||||
'''
|
||||
e = css_find(css).first
|
||||
e.action_chains.move_to_element_with_offset(e._element, x, y)
|
||||
e.action_chains.click()
|
||||
e.action_chains.perform()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_fill(css_selector, text):
|
||||
world.browser.find_by_css(css_selector).first.fill(text)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_link(partial_text):
|
||||
world.browser.find_link_by_partial_text(partial_text).first.click()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_text(css_selector):
|
||||
|
||||
# Wait for the css selector to appear
|
||||
if world.is_css_present(css_selector):
|
||||
return world.browser.find_by_css(css_selector).first.text
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_visible(css_selector):
|
||||
return world.browser.find_by_css(css_selector).visible
|
||||
|
||||
|
||||
@world.absorb
|
||||
def save_the_html(path='/tmp'):
|
||||
u = world.browser.url
|
||||
html = world.browser.html.encode('ascii', 'ignore')
|
||||
filename = '%s.html' % quote_plus(u)
|
||||
f = open('%s/%s' % (path, filename), 'w')
|
||||
f.write(html)
|
||||
f.close
|
||||
@@ -1,30 +0,0 @@
|
||||
import time
|
||||
import datetime
|
||||
import re
|
||||
import calendar
|
||||
|
||||
|
||||
def time_to_date(time_obj):
|
||||
"""
|
||||
Convert a time.time_struct to a true universal time (can pass to js Date constructor)
|
||||
"""
|
||||
# TODO change to using the isoformat() function on datetime. js date can parse those
|
||||
return calendar.timegm(time_obj) * 1000
|
||||
|
||||
|
||||
def jsdate_to_time(field):
|
||||
"""
|
||||
Convert a universal time (iso format) or msec since epoch to a time obj
|
||||
"""
|
||||
if field is None:
|
||||
return field
|
||||
elif isinstance(field, basestring):
|
||||
# ISO format but ignores time zone assuming it's Z.
|
||||
d = datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable
|
||||
return d.utctimetuple()
|
||||
elif isinstance(field, (int, long, float)):
|
||||
return time.gmtime(field / 1000)
|
||||
elif isinstance(field, time.struct_time):
|
||||
return field
|
||||
else:
|
||||
raise ValueError("Couldn't convert %r to time" % field)
|
||||
@@ -16,7 +16,6 @@ This is used by capa_module.
|
||||
from __future__ import division
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import numpy
|
||||
@@ -32,9 +31,9 @@ from xml.sax.saxutils import unescape
|
||||
from copy import deepcopy
|
||||
|
||||
import chem
|
||||
import chem.miller
|
||||
import chem.chemcalc
|
||||
import chem.chemtools
|
||||
import chem.miller
|
||||
import verifiers
|
||||
import verifiers.draganddrop
|
||||
|
||||
@@ -97,8 +96,13 @@ class LoncapaProblem(object):
|
||||
|
||||
- problem_text (string): xml defining the problem
|
||||
- id (string): identifier for this problem; often a filename (no spaces)
|
||||
- state (dict): student state
|
||||
- seed (int): random number generator seed (int)
|
||||
- seed (int): random number generator seed (int)
|
||||
- state (dict): containing the following keys:
|
||||
- 'seed' - (int) random number generator seed
|
||||
- 'student_answers' - (dict) maps input id to the stored answer for that input
|
||||
- 'correct_map' (CorrectMap) a map of each input to their 'correctness'
|
||||
- 'done' - (bool) indicates whether or not this problem is considered done
|
||||
- 'input_state' - (dict) maps input_id to a dictionary that holds the state for that input
|
||||
- system (ModuleSystem): ModuleSystem instance which provides OS,
|
||||
rendering, and user context
|
||||
|
||||
@@ -110,21 +114,23 @@ class LoncapaProblem(object):
|
||||
self.system = system
|
||||
if self.system is None:
|
||||
raise Exception()
|
||||
self.seed = seed
|
||||
|
||||
if state:
|
||||
if 'seed' in state:
|
||||
self.seed = state['seed']
|
||||
if 'student_answers' in state:
|
||||
self.student_answers = state['student_answers']
|
||||
if 'correct_map' in state:
|
||||
self.correct_map.set_dict(state['correct_map'])
|
||||
if 'done' in state:
|
||||
self.done = state['done']
|
||||
state = state if state else {}
|
||||
|
||||
# TODO: Does this deplete the Linux entropy pool? Is this fast enough?
|
||||
if not self.seed:
|
||||
# Set seed according to the following priority:
|
||||
# 1. Contained in problem's state
|
||||
# 2. Passed into capa_problem via constructor
|
||||
# 3. Assign from the OS's random number generator
|
||||
self.seed = state.get('seed', seed)
|
||||
if self.seed is None:
|
||||
self.seed = struct.unpack('i', os.urandom(4))[0]
|
||||
self.student_answers = state.get('student_answers', {})
|
||||
if 'correct_map' in state:
|
||||
self.correct_map.set_dict(state['correct_map'])
|
||||
self.done = state.get('done', False)
|
||||
self.input_state = state.get('input_state', {})
|
||||
|
||||
|
||||
|
||||
# Convert startouttext and endouttext to proper <text></text>
|
||||
problem_text = re.sub("startouttext\s*/", "text", problem_text)
|
||||
@@ -188,6 +194,7 @@ class LoncapaProblem(object):
|
||||
return {'seed': self.seed,
|
||||
'student_answers': self.student_answers,
|
||||
'correct_map': self.correct_map.get_dict(),
|
||||
'input_state': self.input_state,
|
||||
'done': self.done}
|
||||
|
||||
def get_max_score(self):
|
||||
@@ -237,6 +244,20 @@ class LoncapaProblem(object):
|
||||
self.correct_map.set_dict(cmap.get_dict())
|
||||
return cmap
|
||||
|
||||
def ungraded_response(self, xqueue_msg, queuekey):
|
||||
'''
|
||||
Handle any responses from the xqueue that do not contain grades
|
||||
Will try to pass the queue message to all inputtypes that can handle ungraded responses
|
||||
|
||||
Does not return any value
|
||||
'''
|
||||
# check against each inputtype
|
||||
for the_input in self.inputs.values():
|
||||
# if the input type has an ungraded function, pass in the values
|
||||
if hasattr(the_input, 'ungraded_response'):
|
||||
the_input.ungraded_response(xqueue_msg, queuekey)
|
||||
|
||||
|
||||
def is_queued(self):
|
||||
'''
|
||||
Returns True if any part of the problem has been submitted to an external queue
|
||||
@@ -351,7 +372,7 @@ class LoncapaProblem(object):
|
||||
dispatch = get['dispatch']
|
||||
return self.inputs[input_id].handle_ajax(dispatch, get)
|
||||
else:
|
||||
log.warning("Could not find matching input for id: %s" % problem_id)
|
||||
log.warning("Could not find matching input for id: %s" % input_id)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -527,11 +548,15 @@ class LoncapaProblem(object):
|
||||
value = ""
|
||||
if self.student_answers and problemid in self.student_answers:
|
||||
value = self.student_answers[problemid]
|
||||
|
||||
|
||||
if input_id not in self.input_state:
|
||||
self.input_state[input_id] = {}
|
||||
|
||||
# do the rendering
|
||||
state = {'value': value,
|
||||
'status': status,
|
||||
'id': input_id,
|
||||
'input_state': self.input_state[input_id],
|
||||
'feedback': {'message': msg,
|
||||
'hint': hint,
|
||||
'hintmode': hintmode, }}
|
||||
|
||||
@@ -80,16 +80,17 @@ class CorrectMap(object):
|
||||
|
||||
Special migration case:
|
||||
If correct_map is a one-level dict, then convert it to the new dict of dicts format.
|
||||
'''
|
||||
if correct_map and not (type(correct_map[correct_map.keys()[0]]) == dict):
|
||||
# empty current dict
|
||||
self.__init__()
|
||||
|
||||
# create new dict entries
|
||||
'''
|
||||
# empty current dict
|
||||
self.__init__()
|
||||
|
||||
# create new dict entries
|
||||
if correct_map and not isinstance(correct_map.values()[0], dict):
|
||||
# special migration
|
||||
for k in correct_map:
|
||||
self.set(k, correct_map[k])
|
||||
self.set(k, correctness=correct_map[k])
|
||||
else:
|
||||
self.__init__()
|
||||
for k in correct_map:
|
||||
self.set(k, **correct_map[k])
|
||||
|
||||
|
||||
@@ -37,18 +37,18 @@ graded status as'status'
|
||||
# makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a
|
||||
# general css and layout strategy for capa, document it, then implement it.
|
||||
|
||||
from collections import namedtuple
|
||||
import json
|
||||
import logging
|
||||
from lxml import etree
|
||||
import re
|
||||
import shlex # for splitting quoted strings
|
||||
import sys
|
||||
import os
|
||||
import pyparsing
|
||||
|
||||
from .registry import TagRegistry
|
||||
from capa.chem import chemcalc
|
||||
import xqueue_interface
|
||||
from datetime import datetime
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -97,7 +97,8 @@ class Attribute(object):
|
||||
"""
|
||||
val = element.get(self.name)
|
||||
if self.default == self._sentinel and val is None:
|
||||
raise ValueError('Missing required attribute {0}.'.format(self.name))
|
||||
raise ValueError(
|
||||
'Missing required attribute {0}.'.format(self.name))
|
||||
|
||||
if val is None:
|
||||
# not required, so return default
|
||||
@@ -132,6 +133,8 @@ class InputTypeBase(object):
|
||||
* 'id' -- the id of this input, typically
|
||||
"{problem-location}_{response-num}_{input-num}"
|
||||
* 'status' (answered, unanswered, unsubmitted)
|
||||
* 'input_state' -- dictionary containing any inputtype-specific state
|
||||
that has been preserved
|
||||
* 'feedback' (dictionary containing keys for hints, errors, or other
|
||||
feedback from previous attempt. Specifically 'message', 'hint',
|
||||
'hintmode'. If 'hintmode' is 'always', the hint is always displayed.)
|
||||
@@ -149,7 +152,8 @@ class InputTypeBase(object):
|
||||
|
||||
self.id = state.get('id', xml.get('id'))
|
||||
if self.id is None:
|
||||
raise ValueError("input id state is None. xml is {0}".format(etree.tostring(xml)))
|
||||
raise ValueError("input id state is None. xml is {0}".format(
|
||||
etree.tostring(xml)))
|
||||
|
||||
self.value = state.get('value', '')
|
||||
|
||||
@@ -157,6 +161,7 @@ class InputTypeBase(object):
|
||||
self.msg = feedback.get('message', '')
|
||||
self.hint = feedback.get('hint', '')
|
||||
self.hintmode = feedback.get('hintmode', None)
|
||||
self.input_state = state.get('input_state', {})
|
||||
|
||||
# put hint above msg if it should be displayed
|
||||
if self.hintmode == 'always':
|
||||
@@ -169,14 +174,15 @@ class InputTypeBase(object):
|
||||
self.process_requirements()
|
||||
|
||||
# Call subclass "constructor" -- means they don't have to worry about calling
|
||||
# super().__init__, and are isolated from changes to the input constructor interface.
|
||||
# super().__init__, and are isolated from changes to the input
|
||||
# constructor interface.
|
||||
self.setup()
|
||||
except Exception as err:
|
||||
# Something went wrong: add xml to message, but keep the traceback
|
||||
msg = "Error in xml '{x}': {err} ".format(x=etree.tostring(xml), err=str(err))
|
||||
msg = "Error in xml '{x}': {err} ".format(
|
||||
x=etree.tostring(xml), err=str(err))
|
||||
raise Exception, msg, sys.exc_info()[2]
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
@@ -186,7 +192,6 @@ class InputTypeBase(object):
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
def process_requirements(self):
|
||||
"""
|
||||
Subclasses can declare lists of required and optional attributes. This
|
||||
@@ -196,7 +201,8 @@ class InputTypeBase(object):
|
||||
Processes attributes, putting the results in the self.loaded_attributes dictionary. Also creates a set
|
||||
self.to_render, containing the names of attributes that should be included in the context by default.
|
||||
"""
|
||||
# Use local dicts and sets so that if there are exceptions, we don't end up in a partially-initialized state.
|
||||
# Use local dicts and sets so that if there are exceptions, we don't
|
||||
# end up in a partially-initialized state.
|
||||
loaded = {}
|
||||
to_render = set()
|
||||
for a in self.get_attributes():
|
||||
@@ -226,7 +232,7 @@ class InputTypeBase(object):
|
||||
get: a dictionary containing the data that was sent with the ajax call
|
||||
|
||||
Output:
|
||||
a dictionary object that can be serialized into JSON. This will be sent back to the Javascript.
|
||||
a dictionary object that can be serialized into JSON. This will be sent back to the Javascript.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -247,8 +253,9 @@ class InputTypeBase(object):
|
||||
'value': self.value,
|
||||
'status': self.status,
|
||||
'msg': self.msg,
|
||||
}
|
||||
context.update((a, v) for (a, v) in self.loaded_attributes.iteritems() if a in self.to_render)
|
||||
}
|
||||
context.update((a, v) for (
|
||||
a, v) in self.loaded_attributes.iteritems() if a in self.to_render)
|
||||
context.update(self._extra_context())
|
||||
return context
|
||||
|
||||
@@ -371,7 +378,6 @@ class ChoiceGroup(InputTypeBase):
|
||||
return [Attribute("show_correctness", "always"),
|
||||
Attribute("submitted_message", "Answer received.")]
|
||||
|
||||
|
||||
def _extra_context(self):
|
||||
return {'input_type': self.html_input_type,
|
||||
'choices': self.choices,
|
||||
@@ -436,7 +442,6 @@ class JavascriptInput(InputTypeBase):
|
||||
Attribute('display_class', None),
|
||||
Attribute('display_file', None), ]
|
||||
|
||||
|
||||
def setup(self):
|
||||
# Need to provide a value that JSON can parse if there is no
|
||||
# student-supplied value yet.
|
||||
@@ -459,7 +464,6 @@ class TextLine(InputTypeBase):
|
||||
template = "textline.html"
|
||||
tags = ['textline']
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
@@ -474,12 +478,12 @@ class TextLine(InputTypeBase):
|
||||
|
||||
# Attributes below used in setup(), not rendered directly.
|
||||
Attribute('math', None, render=False),
|
||||
# TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x
|
||||
# TODO: 'dojs' flag is temporary, for backwards compatibility with
|
||||
# 8.02x
|
||||
Attribute('dojs', None, render=False),
|
||||
Attribute('preprocessorClassName', None, render=False),
|
||||
Attribute('preprocessorSrc', None, render=False),
|
||||
]
|
||||
|
||||
]
|
||||
|
||||
def setup(self):
|
||||
self.do_math = bool(self.loaded_attributes['math'] or
|
||||
@@ -490,12 +494,12 @@ class TextLine(InputTypeBase):
|
||||
self.preprocessor = None
|
||||
if self.do_math:
|
||||
# Preprocessor to insert between raw input and Mathjax
|
||||
self.preprocessor = {'class_name': self.loaded_attributes['preprocessorClassName'],
|
||||
'script_src': self.loaded_attributes['preprocessorSrc']}
|
||||
self.preprocessor = {
|
||||
'class_name': self.loaded_attributes['preprocessorClassName'],
|
||||
'script_src': self.loaded_attributes['preprocessorSrc']}
|
||||
if None in self.preprocessor.values():
|
||||
self.preprocessor = None
|
||||
|
||||
|
||||
def _extra_context(self):
|
||||
return {'do_math': self.do_math,
|
||||
'preprocessor': self.preprocessor, }
|
||||
@@ -539,7 +543,8 @@ class FileSubmission(InputTypeBase):
|
||||
"""
|
||||
# Check if problem has been queued
|
||||
self.queue_len = 0
|
||||
# Flag indicating that the problem has been queued, 'msg' is length of queue
|
||||
# Flag indicating that the problem has been queued, 'msg' is length of
|
||||
# queue
|
||||
if self.status == 'incomplete':
|
||||
self.status = 'queued'
|
||||
self.queue_len = self.msg
|
||||
@@ -547,7 +552,6 @@ class FileSubmission(InputTypeBase):
|
||||
|
||||
def _extra_context(self):
|
||||
return {'queue_len': self.queue_len, }
|
||||
return context
|
||||
|
||||
registry.register(FileSubmission)
|
||||
|
||||
@@ -562,8 +566,9 @@ class CodeInput(InputTypeBase):
|
||||
|
||||
template = "codeinput.html"
|
||||
tags = ['codeinput',
|
||||
'textbox', # Another (older) name--at some point we may want to make it use a
|
||||
# non-codemirror editor.
|
||||
'textbox',
|
||||
# Another (older) name--at some point we may want to make it use a
|
||||
# non-codemirror editor.
|
||||
]
|
||||
|
||||
# pulled out for testing
|
||||
@@ -586,22 +591,29 @@ class CodeInput(InputTypeBase):
|
||||
Attribute('tabsize', 4, transform=int),
|
||||
]
|
||||
|
||||
def setup(self):
|
||||
def setup_code_response_rendering(self):
|
||||
"""
|
||||
Implement special logic: handle queueing state, and default input.
|
||||
"""
|
||||
# if no student input yet, then use the default input given by the problem
|
||||
if not self.value:
|
||||
self.value = self.xml.text
|
||||
# if no student input yet, then use the default input given by the
|
||||
# problem
|
||||
if not self.value and self.xml.text:
|
||||
self.value = self.xml.text.strip()
|
||||
|
||||
# Check if problem has been queued
|
||||
self.queue_len = 0
|
||||
# Flag indicating that the problem has been queued, 'msg' is length of queue
|
||||
# Flag indicating that the problem has been queued, 'msg' is length of
|
||||
# queue
|
||||
if self.status == 'incomplete':
|
||||
self.status = 'queued'
|
||||
self.queue_len = self.msg
|
||||
self.msg = self.submitted_msg
|
||||
|
||||
|
||||
def setup(self):
|
||||
''' setup this input type '''
|
||||
self.setup_code_response_rendering()
|
||||
|
||||
def _extra_context(self):
|
||||
"""Defined queue_len, add it """
|
||||
return {'queue_len': self.queue_len, }
|
||||
@@ -610,8 +622,164 @@ registry.register(CodeInput)
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MatlabInput(CodeInput):
|
||||
'''
|
||||
InputType for handling Matlab code input
|
||||
|
||||
TODO: API_KEY will go away once we have a way to specify it per-course
|
||||
Example:
|
||||
<matlabinput rows="10" cols="80" tabsize="4">
|
||||
Initial Text
|
||||
<plot_payload>
|
||||
%api_key=API_KEY
|
||||
</plot_payload>
|
||||
</matlabinput>
|
||||
'''
|
||||
template = "matlabinput.html"
|
||||
tags = ['matlabinput']
|
||||
|
||||
plot_submitted_msg = ("Submitted. As soon as a response is returned, "
|
||||
"this message will be replaced by that feedback.")
|
||||
|
||||
def setup(self):
|
||||
'''
|
||||
Handle matlab-specific parsing
|
||||
'''
|
||||
self.setup_code_response_rendering()
|
||||
|
||||
xml = self.xml
|
||||
self.plot_payload = xml.findtext('./plot_payload')
|
||||
|
||||
# Check if problem has been queued
|
||||
self.queuename = 'matlab'
|
||||
self.queue_msg = ''
|
||||
if 'queue_msg' in self.input_state and self.status in ['queued','incomplete', 'unsubmitted']:
|
||||
self.queue_msg = self.input_state['queue_msg']
|
||||
if 'queued' in self.input_state and self.input_state['queuestate'] is not None:
|
||||
self.status = 'queued'
|
||||
self.queue_len = 1
|
||||
self.msg = self.plot_submitted_msg
|
||||
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
'''
|
||||
Handle AJAX calls directed to this input
|
||||
|
||||
Args:
|
||||
- dispatch (str) - indicates how we want this ajax call to be handled
|
||||
- get (dict) - dictionary of key-value pairs that contain useful data
|
||||
Returns:
|
||||
|
||||
'''
|
||||
|
||||
if dispatch == 'plot':
|
||||
return self._plot_data(get)
|
||||
return {}
|
||||
|
||||
def ungraded_response(self, queue_msg, queuekey):
|
||||
'''
|
||||
Handle the response from the XQueue
|
||||
Stores the response in the input_state so it can be rendered later
|
||||
|
||||
Args:
|
||||
- queue_msg (str) - message returned from the queue. The message to be rendered
|
||||
- queuekey (str) - a key passed to the queue. Will be matched up to verify that this is the response we're waiting for
|
||||
|
||||
Returns:
|
||||
nothing
|
||||
'''
|
||||
# check the queuekey against the saved queuekey
|
||||
if('queuestate' in self.input_state and self.input_state['queuestate'] == 'queued'
|
||||
and self.input_state['queuekey'] == queuekey):
|
||||
msg = self._parse_data(queue_msg)
|
||||
# save the queue message so that it can be rendered later
|
||||
self.input_state['queue_msg'] = msg
|
||||
self.input_state['queuestate'] = None
|
||||
self.input_state['queuekey'] = None
|
||||
|
||||
def _extra_context(self):
|
||||
''' Set up additional context variables'''
|
||||
extra_context = {
|
||||
'queue_len': self.queue_len,
|
||||
'queue_msg': self.queue_msg
|
||||
}
|
||||
return extra_context
|
||||
|
||||
def _parse_data(self, queue_msg):
|
||||
'''
|
||||
Parses the message out of the queue message
|
||||
Args:
|
||||
queue_msg (str) - a JSON encoded string
|
||||
Returns:
|
||||
returns the value for the the key 'msg' in queue_msg
|
||||
'''
|
||||
try:
|
||||
result = json.loads(queue_msg)
|
||||
except (TypeError, ValueError):
|
||||
log.error("External message should be a JSON serialized dict."
|
||||
" Received queue_msg = %s" % queue_msg)
|
||||
raise
|
||||
msg = result['msg']
|
||||
return msg
|
||||
|
||||
|
||||
def _plot_data(self, get):
|
||||
'''
|
||||
AJAX handler for the plot button
|
||||
Args:
|
||||
get (dict) - should have key 'submission' which contains the student submission
|
||||
Returns:
|
||||
dict - 'success' - whether or not we successfully queued this submission
|
||||
- 'message' - message to be rendered in case of error
|
||||
'''
|
||||
# only send data if xqueue exists
|
||||
if self.system.xqueue is None:
|
||||
return {'success': False, 'message': 'Cannot connect to the queue'}
|
||||
|
||||
# pull relevant info out of get
|
||||
response = get['submission']
|
||||
|
||||
# construct xqueue headers
|
||||
qinterface = self.system.xqueue['interface']
|
||||
qtime = datetime.strftime(datetime.utcnow(), xqueue_interface.dateformat)
|
||||
callback_url = self.system.xqueue['construct_callback']('ungraded_response')
|
||||
anonymous_student_id = self.system.anonymous_student_id
|
||||
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
|
||||
anonymous_student_id +
|
||||
self.id)
|
||||
xheader = xqueue_interface.make_xheader(
|
||||
lms_callback_url = callback_url,
|
||||
lms_key = queuekey,
|
||||
queue_name = self.queuename)
|
||||
|
||||
# save the input state
|
||||
self.input_state['queuekey'] = queuekey
|
||||
self.input_state['queuestate'] = 'queued'
|
||||
|
||||
|
||||
# construct xqueue body
|
||||
student_info = {'anonymous_student_id': anonymous_student_id,
|
||||
'submission_time': qtime}
|
||||
contents = {'grader_payload': self.plot_payload,
|
||||
'student_info': json.dumps(student_info),
|
||||
'student_response': response}
|
||||
|
||||
(error, msg) = qinterface.send_to_queue(header=xheader,
|
||||
body = json.dumps(contents))
|
||||
|
||||
return {'success': error == 0, 'message': msg}
|
||||
|
||||
|
||||
registry.register(MatlabInput)
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class Schematic(InputTypeBase):
|
||||
"""
|
||||
InputType for the schematic editor
|
||||
"""
|
||||
|
||||
template = "schematicinput.html"
|
||||
@@ -630,7 +798,6 @@ class Schematic(InputTypeBase):
|
||||
Attribute('initial_value', None),
|
||||
Attribute('submit_analyses', None), ]
|
||||
|
||||
return context
|
||||
|
||||
registry.register(Schematic)
|
||||
|
||||
@@ -660,12 +827,12 @@ class ImageInput(InputTypeBase):
|
||||
Attribute('height'),
|
||||
Attribute('width'), ]
|
||||
|
||||
|
||||
def setup(self):
|
||||
"""
|
||||
if value is of the form [x,y] then parse it and send along coordinates of previous answer
|
||||
"""
|
||||
m = re.match('\[([0-9]+),([0-9]+)]', self.value.strip().replace(' ', ''))
|
||||
m = re.match('\[([0-9]+),([0-9]+)]',
|
||||
self.value.strip().replace(' ', ''))
|
||||
if m:
|
||||
# Note: we subtract 15 to compensate for the size of the dot on the screen.
|
||||
# (is a 30x30 image--lms/static/green-pointer.png).
|
||||
@@ -673,7 +840,6 @@ class ImageInput(InputTypeBase):
|
||||
else:
|
||||
(self.gx, self.gy) = (0, 0)
|
||||
|
||||
|
||||
def _extra_context(self):
|
||||
|
||||
return {'gx': self.gx,
|
||||
@@ -730,7 +896,7 @@ class VseprInput(InputTypeBase):
|
||||
|
||||
registry.register(VseprInput)
|
||||
|
||||
#--------------------------------------------------------------------------------
|
||||
#-------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChemicalEquationInput(InputTypeBase):
|
||||
@@ -794,7 +960,8 @@ class ChemicalEquationInput(InputTypeBase):
|
||||
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)
|
||||
log.warning(
|
||||
"Error while previewing chemical formula", exc_info=True)
|
||||
result['error'] = "Error while rendering preview"
|
||||
|
||||
return result
|
||||
@@ -843,16 +1010,16 @@ class DragAndDropInput(InputTypeBase):
|
||||
'can_reuse': ""}
|
||||
|
||||
tag_attrs['target'] = {'id': Attribute._sentinel,
|
||||
'x': Attribute._sentinel,
|
||||
'y': Attribute._sentinel,
|
||||
'w': Attribute._sentinel,
|
||||
'h': Attribute._sentinel}
|
||||
'x': Attribute._sentinel,
|
||||
'y': Attribute._sentinel,
|
||||
'w': Attribute._sentinel,
|
||||
'h': Attribute._sentinel}
|
||||
|
||||
dic = dict()
|
||||
|
||||
for attr_name in tag_attrs[tag_type].keys():
|
||||
dic[attr_name] = Attribute(attr_name,
|
||||
default=tag_attrs[tag_type][attr_name]).parse_from_xml(tag)
|
||||
default=tag_attrs[tag_type][attr_name]).parse_from_xml(tag)
|
||||
|
||||
if tag_type == 'draggable' and not self.no_labels:
|
||||
dic['label'] = dic['label'] or dic['id']
|
||||
@@ -865,7 +1032,7 @@ class DragAndDropInput(InputTypeBase):
|
||||
|
||||
# add labels to images?:
|
||||
self.no_labels = Attribute('no_labels',
|
||||
default="False").parse_from_xml(self.xml)
|
||||
default="False").parse_from_xml(self.xml)
|
||||
|
||||
to_js = dict()
|
||||
|
||||
@@ -874,16 +1041,16 @@ class DragAndDropInput(InputTypeBase):
|
||||
|
||||
# outline places on image where to drag adn drop
|
||||
to_js['target_outline'] = Attribute('target_outline',
|
||||
default="False").parse_from_xml(self.xml)
|
||||
default="False").parse_from_xml(self.xml)
|
||||
# one draggable per target?
|
||||
to_js['one_per_target'] = Attribute('one_per_target',
|
||||
default="True").parse_from_xml(self.xml)
|
||||
default="True").parse_from_xml(self.xml)
|
||||
# list of draggables
|
||||
to_js['draggables'] = [parse(draggable, 'draggable') for draggable in
|
||||
self.xml.iterchildren('draggable')]
|
||||
self.xml.iterchildren('draggable')]
|
||||
# list of targets
|
||||
to_js['targets'] = [parse(target, 'target') for target in
|
||||
self.xml.iterchildren('target')]
|
||||
self.xml.iterchildren('target')]
|
||||
|
||||
# custom background color for labels:
|
||||
label_bg_color = Attribute('label_bg_color',
|
||||
@@ -896,7 +1063,7 @@ class DragAndDropInput(InputTypeBase):
|
||||
|
||||
registry.register(DragAndDropInput)
|
||||
|
||||
#--------------------------------------------------------------------------------------------------------------------
|
||||
#-------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EditAMoleculeInput(InputTypeBase):
|
||||
@@ -934,6 +1101,7 @@ registry.register(EditAMoleculeInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DesignProtein2dInput(InputTypeBase):
|
||||
"""
|
||||
An input type for design of a protein in 2D. Integrates with the Protex java applet.
|
||||
@@ -969,14 +1137,16 @@ registry.register(DesignProtein2dInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
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 GWT application.
|
||||
|
||||
Example:
|
||||
|
||||
<editagene width="800" hight="500" dna_sequence="ETAAGGCTATAACCGA" />
|
||||
"""
|
||||
<editagene genex_dna_sequence="CGAT" genex_problem_number="1"/>
|
||||
"""
|
||||
|
||||
template = "editageneinput.html"
|
||||
tags = ['editageneinput']
|
||||
@@ -986,9 +1156,7 @@ class EditAGeneInput(InputTypeBase):
|
||||
"""
|
||||
Note: width, height, and dna_sequencee are required.
|
||||
"""
|
||||
return [Attribute('width'),
|
||||
Attribute('height'),
|
||||
Attribute('dna_sequence'),
|
||||
return [Attribute('genex_dna_sequence'),
|
||||
Attribute('genex_problem_number')
|
||||
]
|
||||
|
||||
@@ -1005,6 +1173,7 @@ registry.register(EditAGeneInput)
|
||||
|
||||
#---------------------------------------------------------------------
|
||||
|
||||
|
||||
class AnnotationInput(InputTypeBase):
|
||||
"""
|
||||
Input type for annotations: students can enter some notes or other text
|
||||
@@ -1037,13 +1206,14 @@ class AnnotationInput(InputTypeBase):
|
||||
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.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.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()
|
||||
|
||||
@@ -1061,7 +1231,7 @@ class AnnotationInput(InputTypeBase):
|
||||
'id': index,
|
||||
'description': option.text,
|
||||
'choice': option.get('choice')
|
||||
} for (index, option) in enumerate(elements) ]
|
||||
} for (index, option) in enumerate(elements)]
|
||||
|
||||
def _validate_options(self):
|
||||
''' Raises a ValueError if the choice attribute is missing or invalid. '''
|
||||
@@ -1071,7 +1241,8 @@ class AnnotationInput(InputTypeBase):
|
||||
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)))
|
||||
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. '''
|
||||
@@ -1089,20 +1260,20 @@ class AnnotationInput(InputTypeBase):
|
||||
|
||||
return {
|
||||
'options_value': options_value,
|
||||
'has_options_value': len(options_value) > 0, # for convenience
|
||||
'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
|
||||
'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))
|
||||
@@ -1110,4 +1281,3 @@ class AnnotationInput(InputTypeBase):
|
||||
return extra_context
|
||||
|
||||
registry.register(AnnotationInput)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import logging
|
||||
import numbers
|
||||
import numpy
|
||||
import os
|
||||
import sys
|
||||
import random
|
||||
import re
|
||||
import requests
|
||||
@@ -52,12 +53,17 @@ class LoncapaProblemError(Exception):
|
||||
|
||||
class ResponseError(Exception):
|
||||
'''
|
||||
Error for failure in processing a response
|
||||
Error for failure in processing a response, including
|
||||
exceptions that occur when executing a custom script.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class StudentInputError(Exception):
|
||||
'''
|
||||
Error for an invalid student input.
|
||||
For example, submitting a string when the problem expects a number
|
||||
'''
|
||||
pass
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
@@ -128,21 +134,25 @@ class LoncapaResponse(object):
|
||||
|
||||
for abox in inputfields:
|
||||
if abox.tag not in self.allowed_inputfields:
|
||||
msg = "%s: cannot have input field %s" % (unicode(self), abox.tag)
|
||||
msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '<unavailable>')
|
||||
msg = "%s: cannot have input field %s" % (
|
||||
unicode(self), abox.tag)
|
||||
msg += "\nSee XML source line %s" % getattr(
|
||||
xml, 'sourceline', '<unavailable>')
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
if self.max_inputfields and len(inputfields) > self.max_inputfields:
|
||||
msg = "%s: cannot have more than %s input fields" % (
|
||||
unicode(self), self.max_inputfields)
|
||||
msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '<unavailable>')
|
||||
msg += "\nSee XML source line %s" % getattr(
|
||||
xml, 'sourceline', '<unavailable>')
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
for prop in self.required_attributes:
|
||||
if not xml.get(prop):
|
||||
msg = "Error in problem specification: %s missing required attribute %s" % (
|
||||
unicode(self), prop)
|
||||
msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '<unavailable>')
|
||||
msg += "\nSee XML source line %s" % getattr(
|
||||
xml, 'sourceline', '<unavailable>')
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
# ordered list of answer_id values for this response
|
||||
@@ -163,7 +173,8 @@ class LoncapaResponse(object):
|
||||
for entry in self.inputfields:
|
||||
answer = entry.get('correct_answer')
|
||||
if answer:
|
||||
self.default_answer_map[entry.get('id')] = contextualize_text(answer, self.context)
|
||||
self.default_answer_map[entry.get(
|
||||
'id')] = contextualize_text(answer, self.context)
|
||||
|
||||
if hasattr(self, 'setup_response'):
|
||||
self.setup_response()
|
||||
@@ -211,7 +222,8 @@ class LoncapaResponse(object):
|
||||
Returns the new CorrectMap, with (correctness,msg,hint,hintmode) for each answer_id.
|
||||
'''
|
||||
new_cmap = self.get_score(student_answers)
|
||||
self.get_hints(convert_files_to_filenames(student_answers), new_cmap, old_cmap)
|
||||
self.get_hints(convert_files_to_filenames(
|
||||
student_answers), new_cmap, old_cmap)
|
||||
# log.debug('new_cmap = %s' % new_cmap)
|
||||
return new_cmap
|
||||
|
||||
@@ -241,14 +253,17 @@ class LoncapaResponse(object):
|
||||
# callback procedure to a social hint generation system.
|
||||
if not hintfn in self.context:
|
||||
msg = 'missing specified hint function %s in script context' % hintfn
|
||||
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>')
|
||||
msg += "\nSee XML source line %s" % getattr(
|
||||
self.xml, 'sourceline', '<unavailable>')
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
try:
|
||||
self.context[hintfn](self.answer_ids, student_answers, new_cmap, old_cmap)
|
||||
self.context[hintfn](
|
||||
self.answer_ids, student_answers, new_cmap, old_cmap)
|
||||
except Exception as err:
|
||||
msg = 'Error %s in evaluating hint function %s' % (err, hintfn)
|
||||
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>')
|
||||
msg += "\nSee XML source line %s" % getattr(
|
||||
self.xml, 'sourceline', '<unavailable>')
|
||||
raise ResponseError(msg)
|
||||
return
|
||||
|
||||
@@ -270,17 +285,19 @@ class LoncapaResponse(object):
|
||||
|
||||
if (self.hint_tag is not None
|
||||
and hintgroup.find(self.hint_tag) is not None
|
||||
and hasattr(self, 'check_hint_condition')):
|
||||
and hasattr(self, 'check_hint_condition')):
|
||||
|
||||
rephints = hintgroup.findall(self.hint_tag)
|
||||
hints_to_show = self.check_hint_condition(rephints, student_answers)
|
||||
hints_to_show = self.check_hint_condition(
|
||||
rephints, student_answers)
|
||||
|
||||
# can be 'on_request' or 'always' (default)
|
||||
hintmode = hintgroup.get('mode', 'always')
|
||||
for hintpart in hintgroup.findall('hintpart'):
|
||||
if hintpart.get('on') in hints_to_show:
|
||||
hint_text = hintpart.find('text').text
|
||||
# make the hint appear after the last answer box in this response
|
||||
# make the hint appear after the last answer box in this
|
||||
# response
|
||||
aid = self.answer_ids[-1]
|
||||
new_cmap.set_hint_and_mode(aid, hint_text, hintmode)
|
||||
log.debug('after hint: new_cmap = %s' % new_cmap)
|
||||
@@ -340,7 +357,6 @@ class LoncapaResponse(object):
|
||||
response_msg_div = etree.Element('div')
|
||||
response_msg_div.text = str(response_msg)
|
||||
|
||||
|
||||
# Set the css class of the message <div>
|
||||
response_msg_div.set("class", "response_message")
|
||||
|
||||
@@ -384,20 +400,20 @@ class JavascriptResponse(LoncapaResponse):
|
||||
# until we decide on exactly how to solve this issue. For now, files are
|
||||
# manually being compiled to DATA_DIR/js/compiled.
|
||||
|
||||
#latestTimestamp = 0
|
||||
#basepath = self.system.filestore.root_path + '/js/'
|
||||
#for filename in (self.display_dependencies + [self.display]):
|
||||
# latestTimestamp = 0
|
||||
# basepath = self.system.filestore.root_path + '/js/'
|
||||
# for filename in (self.display_dependencies + [self.display]):
|
||||
# filepath = basepath + filename
|
||||
# timestamp = os.stat(filepath).st_mtime
|
||||
# if timestamp > latestTimestamp:
|
||||
# latestTimestamp = timestamp
|
||||
#
|
||||
#h = hashlib.md5()
|
||||
#h.update(self.answer_id + str(self.display_dependencies))
|
||||
#compiled_filename = 'compiled/' + h.hexdigest() + '.js'
|
||||
#compiled_filepath = basepath + compiled_filename
|
||||
# h = hashlib.md5()
|
||||
# h.update(self.answer_id + str(self.display_dependencies))
|
||||
# compiled_filename = 'compiled/' + h.hexdigest() + '.js'
|
||||
# compiled_filepath = basepath + compiled_filename
|
||||
|
||||
#if not os.path.exists(compiled_filepath) or os.stat(compiled_filepath).st_mtime < latestTimestamp:
|
||||
# if not os.path.exists(compiled_filepath) or os.stat(compiled_filepath).st_mtime < latestTimestamp:
|
||||
# outfile = open(compiled_filepath, 'w')
|
||||
# for filename in (self.display_dependencies + [self.display]):
|
||||
# filepath = basepath + filename
|
||||
@@ -419,7 +435,7 @@ class JavascriptResponse(LoncapaResponse):
|
||||
id=self.xml.get('id'))[0]
|
||||
|
||||
self.display_xml = self.xml.xpath('//*[@id=$id]//display',
|
||||
id=self.xml.get('id'))[0]
|
||||
id=self.xml.get('id'))[0]
|
||||
|
||||
self.xml.remove(self.generator_xml)
|
||||
self.xml.remove(self.grader_xml)
|
||||
@@ -430,17 +446,20 @@ class JavascriptResponse(LoncapaResponse):
|
||||
self.display = self.display_xml.get("src")
|
||||
|
||||
if self.generator_xml.get("dependencies"):
|
||||
self.generator_dependencies = self.generator_xml.get("dependencies").split()
|
||||
self.generator_dependencies = self.generator_xml.get(
|
||||
"dependencies").split()
|
||||
else:
|
||||
self.generator_dependencies = []
|
||||
|
||||
if self.grader_xml.get("dependencies"):
|
||||
self.grader_dependencies = self.grader_xml.get("dependencies").split()
|
||||
self.grader_dependencies = self.grader_xml.get(
|
||||
"dependencies").split()
|
||||
else:
|
||||
self.grader_dependencies = []
|
||||
|
||||
if self.display_xml.get("dependencies"):
|
||||
self.display_dependencies = self.display_xml.get("dependencies").split()
|
||||
self.display_dependencies = self.display_xml.get(
|
||||
"dependencies").split()
|
||||
else:
|
||||
self.display_dependencies = []
|
||||
|
||||
@@ -461,10 +480,10 @@ class JavascriptResponse(LoncapaResponse):
|
||||
|
||||
return subprocess.check_output(subprocess_args, env=self.get_node_env())
|
||||
|
||||
|
||||
def generate_problem_state(self):
|
||||
|
||||
generator_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_generator.js'
|
||||
generator_file = os.path.dirname(os.path.normpath(
|
||||
__file__)) + '/javascript_problem_generator.js'
|
||||
output = self.call_node([generator_file,
|
||||
self.generator,
|
||||
json.dumps(self.generator_dependencies),
|
||||
@@ -478,17 +497,18 @@ class JavascriptResponse(LoncapaResponse):
|
||||
params = {}
|
||||
|
||||
for param in self.xml.xpath('//*[@id=$id]//responseparam',
|
||||
id=self.xml.get('id')):
|
||||
id=self.xml.get('id')):
|
||||
|
||||
raw_param = param.get("value")
|
||||
params[param.get("name")] = json.loads(contextualize_text(raw_param, self.context))
|
||||
params[param.get("name")] = json.loads(
|
||||
contextualize_text(raw_param, self.context))
|
||||
|
||||
return params
|
||||
|
||||
def prepare_inputfield(self):
|
||||
|
||||
for inputfield in self.xml.xpath('//*[@id=$id]//javascriptinput',
|
||||
id=self.xml.get('id')):
|
||||
id=self.xml.get('id')):
|
||||
|
||||
escapedict = {'"': '"'}
|
||||
|
||||
@@ -501,7 +521,7 @@ class JavascriptResponse(LoncapaResponse):
|
||||
escapedict)
|
||||
inputfield.set("problem_state", encoded_problem_state)
|
||||
|
||||
inputfield.set("display_file", self.display_filename)
|
||||
inputfield.set("display_file", self.display_filename)
|
||||
inputfield.set("display_class", self.display_class)
|
||||
|
||||
def get_score(self, student_answers):
|
||||
@@ -519,7 +539,8 @@ class JavascriptResponse(LoncapaResponse):
|
||||
if submission is None or submission == '':
|
||||
submission = json.dumps(None)
|
||||
|
||||
grader_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_grader.js'
|
||||
grader_file = os.path.dirname(os.path.normpath(
|
||||
__file__)) + '/javascript_problem_grader.js'
|
||||
outputs = self.call_node([grader_file,
|
||||
self.grader,
|
||||
json.dumps(self.grader_dependencies),
|
||||
@@ -528,8 +549,8 @@ class JavascriptResponse(LoncapaResponse):
|
||||
json.dumps(self.params)]).split('\n')
|
||||
|
||||
all_correct = json.loads(outputs[0].strip())
|
||||
evaluation = outputs[1].strip()
|
||||
solution = outputs[2].strip()
|
||||
evaluation = outputs[1].strip()
|
||||
solution = outputs[2].strip()
|
||||
return (all_correct, evaluation, solution)
|
||||
|
||||
def get_answers(self):
|
||||
@@ -539,9 +560,7 @@ class JavascriptResponse(LoncapaResponse):
|
||||
return {self.answer_id: self.solution}
|
||||
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class ChoiceResponse(LoncapaResponse):
|
||||
"""
|
||||
This response type is used when the student chooses from a discrete set of
|
||||
@@ -599,9 +618,10 @@ class ChoiceResponse(LoncapaResponse):
|
||||
self.assign_choice_names()
|
||||
|
||||
correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]',
|
||||
id=self.xml.get('id'))
|
||||
id=self.xml.get('id'))
|
||||
|
||||
self.correct_choices = set([choice.get('name') for choice in correct_xml])
|
||||
self.correct_choices = set([choice.get(
|
||||
'name') for choice in correct_xml])
|
||||
|
||||
def assign_choice_names(self):
|
||||
'''
|
||||
@@ -654,7 +674,8 @@ class MultipleChoiceResponse(LoncapaResponse):
|
||||
allowed_inputfields = ['choicegroup']
|
||||
|
||||
def setup_response(self):
|
||||
# call secondary setup for MultipleChoice questions, to set name attributes
|
||||
# call secondary setup for MultipleChoice questions, to set name
|
||||
# attributes
|
||||
self.mc_setup_response()
|
||||
|
||||
# define correct choices (after calling secondary setup)
|
||||
@@ -692,7 +713,7 @@ class MultipleChoiceResponse(LoncapaResponse):
|
||||
# log.debug('%s: student_answers=%s, correct_choices=%s' % (
|
||||
# unicode(self), student_answers, self.correct_choices))
|
||||
if (self.answer_id in student_answers
|
||||
and student_answers[self.answer_id] in self.correct_choices):
|
||||
and student_answers[self.answer_id] in self.correct_choices):
|
||||
return CorrectMap(self.answer_id, 'correct')
|
||||
else:
|
||||
return CorrectMap(self.answer_id, 'incorrect')
|
||||
@@ -760,7 +781,8 @@ class OptionResponse(LoncapaResponse):
|
||||
return cmap
|
||||
|
||||
def get_answers(self):
|
||||
amap = dict([(af.get('id'), contextualize_text(af.get('correct'), self.context)) for af in self.answer_fields])
|
||||
amap = dict([(af.get('id'), contextualize_text(af.get(
|
||||
'correct'), self.context)) for af in self.answer_fields])
|
||||
# log.debug('%s: expected answers=%s' % (unicode(self),amap))
|
||||
return amap
|
||||
|
||||
@@ -780,8 +802,9 @@ class NumericalResponse(LoncapaResponse):
|
||||
context = self.context
|
||||
self.correct_answer = contextualize_text(xml.get('answer'), context)
|
||||
try:
|
||||
self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default',
|
||||
id=xml.get('id'))[0]
|
||||
self.tolerance_xml = xml.xpath(
|
||||
'//*[@id=$id]//responseparam[@type="tolerance"]/@default',
|
||||
id=xml.get('id'))[0]
|
||||
self.tolerance = contextualize_text(self.tolerance_xml, context)
|
||||
except Exception:
|
||||
self.tolerance = '0'
|
||||
@@ -798,21 +821,25 @@ class NumericalResponse(LoncapaResponse):
|
||||
try:
|
||||
correct_ans = complex(self.correct_answer)
|
||||
except ValueError:
|
||||
log.debug("Content error--answer '{0}' is not a valid complex number".format(self.correct_answer))
|
||||
raise StudentInputError("There was a problem with the staff answer to this problem")
|
||||
log.debug("Content error--answer '{0}' is not a valid complex number".format(
|
||||
self.correct_answer))
|
||||
raise StudentInputError(
|
||||
"There was a problem with the staff answer to this problem")
|
||||
|
||||
try:
|
||||
correct = compare_with_tolerance(evaluator(dict(), dict(), student_answer),
|
||||
correct_ans, self.tolerance)
|
||||
correct = compare_with_tolerance(
|
||||
evaluator(dict(), dict(), student_answer),
|
||||
correct_ans, self.tolerance)
|
||||
# We should catch this explicitly.
|
||||
# I think this is just pyparsing.ParseException, calc.UndefinedVariable:
|
||||
# But we'd need to confirm
|
||||
except:
|
||||
# Use the traceback-preserving version of re-raising with a different type
|
||||
# Use the traceback-preserving version of re-raising with a
|
||||
# different type
|
||||
import sys
|
||||
type, value, traceback = sys.exc_info()
|
||||
|
||||
raise StudentInputError, ("Invalid input: could not interpret '%s' as a number" %
|
||||
raise StudentInputError, ("Could not interpret '%s' as a number" %
|
||||
cgi.escape(student_answer)), traceback
|
||||
|
||||
if correct:
|
||||
@@ -837,7 +864,8 @@ class StringResponse(LoncapaResponse):
|
||||
max_inputfields = 1
|
||||
|
||||
def setup_response(self):
|
||||
self.correct_answer = contextualize_text(self.xml.get('answer'), self.context).strip()
|
||||
self.correct_answer = contextualize_text(
|
||||
self.xml.get('answer'), self.context).strip()
|
||||
|
||||
def get_score(self, student_answers):
|
||||
'''Grade a string response '''
|
||||
@@ -846,7 +874,8 @@ class StringResponse(LoncapaResponse):
|
||||
return CorrectMap(self.answer_id, 'correct' if correct else 'incorrect')
|
||||
|
||||
def check_string(self, expected, given):
|
||||
if self.xml.get('type') == 'ci': return given.lower() == expected.lower()
|
||||
if self.xml.get('type') == 'ci':
|
||||
return given.lower() == expected.lower()
|
||||
return given == expected
|
||||
|
||||
def check_hint_condition(self, hxml_set, student_answers):
|
||||
@@ -854,8 +883,10 @@ class StringResponse(LoncapaResponse):
|
||||
hints_to_show = []
|
||||
for hxml in hxml_set:
|
||||
name = hxml.get('name')
|
||||
correct_answer = contextualize_text(hxml.get('answer'), self.context).strip()
|
||||
if self.check_string(correct_answer, given): hints_to_show.append(name)
|
||||
correct_answer = contextualize_text(
|
||||
hxml.get('answer'), self.context).strip()
|
||||
if self.check_string(correct_answer, given):
|
||||
hints_to_show.append(name)
|
||||
log.debug('hints_to_show = %s' % hints_to_show)
|
||||
return hints_to_show
|
||||
|
||||
@@ -889,7 +920,7 @@ class CustomResponse(LoncapaResponse):
|
||||
correct[0] ='incorrect'
|
||||
</answer>
|
||||
</customresponse>"""},
|
||||
{'snippet': """<script type="loncapa/python"><![CDATA[
|
||||
{'snippet': """<script type="loncapa/python"><![CDATA[
|
||||
|
||||
def sympy_check2():
|
||||
messages[0] = '%s:%s' % (submission[0],fromjs[0].replace('<','<'))
|
||||
@@ -907,15 +938,16 @@ def sympy_check2():
|
||||
response_tag = 'customresponse'
|
||||
|
||||
allowed_inputfields = ['textline', 'textbox', 'crystallography',
|
||||
'chemicalequationinput', 'vsepr_input',
|
||||
'drag_and_drop_input', 'editamoleculeinput',
|
||||
'designprotein2dinput', 'editageneinput',
|
||||
'annotationinput']
|
||||
'chemicalequationinput', 'vsepr_input',
|
||||
'drag_and_drop_input', 'editamoleculeinput',
|
||||
'designprotein2dinput', 'editageneinput',
|
||||
'annotationinput']
|
||||
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
|
||||
# if <customresponse> has an "expect" (or "answer") attribute then save that
|
||||
# if <customresponse> has an "expect" (or "answer") attribute then save
|
||||
# that
|
||||
self.expect = xml.get('expect') or xml.get('answer')
|
||||
self.myid = xml.get('id')
|
||||
|
||||
@@ -939,7 +971,8 @@ def sympy_check2():
|
||||
if cfn in self.context:
|
||||
self.code = self.context[cfn]
|
||||
else:
|
||||
msg = "%s: can't find cfn %s in context" % (unicode(self), cfn)
|
||||
msg = "%s: can't find cfn %s in context" % (
|
||||
unicode(self), cfn)
|
||||
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline',
|
||||
'<unavailable>')
|
||||
raise LoncapaProblemError(msg)
|
||||
@@ -952,7 +985,8 @@ def sympy_check2():
|
||||
else:
|
||||
answer_src = answer.get('src')
|
||||
if answer_src is not None:
|
||||
self.code = self.system.filesystem.open('src/' + answer_src).read()
|
||||
self.code = self.system.filesystem.open(
|
||||
'src/' + answer_src).read()
|
||||
else:
|
||||
self.code = answer.text
|
||||
|
||||
@@ -1032,7 +1066,7 @@ def sympy_check2():
|
||||
# any options to be passed to the cfn
|
||||
'options': self.xml.get('options'),
|
||||
'testdat': 'hello world',
|
||||
})
|
||||
})
|
||||
|
||||
# pass self.system.debug to cfn
|
||||
self.context['debug'] = self.system.DEBUG
|
||||
@@ -1044,12 +1078,10 @@ def sympy_check2():
|
||||
correct = self.context['correct']
|
||||
messages = self.context['messages']
|
||||
overall_message = self.context['overall_message']
|
||||
|
||||
except Exception as err:
|
||||
print "oops in customresponse (code) error %s" % err
|
||||
print "context = ", self.context
|
||||
print traceback.format_exc()
|
||||
# Notify student
|
||||
raise StudentInputError("Error: Problem could not be evaluated with your input")
|
||||
self._handle_exec_exception(err)
|
||||
|
||||
else:
|
||||
# self.code is not a string; assume its a function
|
||||
|
||||
@@ -1058,26 +1090,27 @@ def sympy_check2():
|
||||
ret = None
|
||||
log.debug(" submission = %s" % submission)
|
||||
try:
|
||||
answer_given = submission[0] if (len(idset) == 1) else submission
|
||||
answer_given = submission[0] if (
|
||||
len(idset) == 1) else submission
|
||||
# handle variable number of arguments in check function, for backwards compatibility
|
||||
# with various Tutor2 check functions
|
||||
args = [self.expect, answer_given, student_answers, self.answer_ids[0]]
|
||||
args = [self.expect, answer_given,
|
||||
student_answers, self.answer_ids[0]]
|
||||
argspec = inspect.getargspec(fn)
|
||||
nargs = len(argspec.args) - len(argspec.defaults or [])
|
||||
kwargs = {}
|
||||
for argname in argspec.args[nargs:]:
|
||||
kwargs[argname] = self.context[argname] if argname in self.context else None
|
||||
kwargs[argname] = self.context[
|
||||
argname] if argname in self.context else None
|
||||
|
||||
log.debug('[customresponse] answer_given=%s' % answer_given)
|
||||
log.debug('nargs=%d, args=%s, kwargs=%s' % (nargs, args, kwargs))
|
||||
log.debug('nargs=%d, args=%s, kwargs=%s' % (
|
||||
nargs, args, kwargs))
|
||||
|
||||
ret = fn(*args[:nargs], **kwargs)
|
||||
|
||||
except Exception as err:
|
||||
log.error("oops in customresponse (cfn) error %s" % err)
|
||||
# print "context = ",self.context
|
||||
log.error(traceback.format_exc())
|
||||
raise Exception("oops in customresponse (cfn) error %s" % err)
|
||||
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret)
|
||||
self._handle_exec_exception(err)
|
||||
|
||||
if type(ret) == dict:
|
||||
|
||||
@@ -1086,7 +1119,8 @@ def sympy_check2():
|
||||
# If there are multiple inputs, they all get marked
|
||||
# to the same correct/incorrect value
|
||||
if 'ok' in ret:
|
||||
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
|
||||
correct = ['correct'] * len(idset) if ret[
|
||||
'ok'] else ['incorrect'] * len(idset)
|
||||
msg = ret.get('msg', None)
|
||||
msg = self.clean_message_html(msg)
|
||||
|
||||
@@ -1097,7 +1131,6 @@ def sympy_check2():
|
||||
else:
|
||||
messages[0] = msg
|
||||
|
||||
|
||||
# Another kind of dictionary the check function can return has
|
||||
# the form:
|
||||
# {'overall_message': STRING,
|
||||
@@ -1113,21 +1146,25 @@ def sympy_check2():
|
||||
correct = []
|
||||
messages = []
|
||||
for input_dict in input_list:
|
||||
correct.append('correct' if input_dict['ok'] else 'incorrect')
|
||||
msg = self.clean_message_html(input_dict['msg']) if 'msg' in input_dict else None
|
||||
correct.append('correct'
|
||||
if input_dict['ok'] else 'incorrect')
|
||||
msg = (self.clean_message_html(input_dict['msg'])
|
||||
if 'msg' in input_dict else None)
|
||||
messages.append(msg)
|
||||
|
||||
# Otherwise, we do not recognize the dictionary
|
||||
# Raise an exception
|
||||
else:
|
||||
log.error(traceback.format_exc())
|
||||
raise Exception("CustomResponse: check function returned an invalid dict")
|
||||
raise ResponseError(
|
||||
"CustomResponse: check function returned an invalid dict")
|
||||
|
||||
# The check function can return a boolean value,
|
||||
# indicating whether all inputs should be marked
|
||||
# correct or incorrect
|
||||
else:
|
||||
correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset)
|
||||
n = len(idset)
|
||||
correct = ['correct'] * n if ret else ['incorrect'] * n
|
||||
|
||||
# build map giving "correct"ness of the answer(s)
|
||||
correct_map = CorrectMap()
|
||||
@@ -1136,7 +1173,8 @@ def sympy_check2():
|
||||
correct_map.set_overall_message(overall_message)
|
||||
|
||||
for k in range(len(idset)):
|
||||
npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0
|
||||
npoints = (self.maxpoints[idset[k]]
|
||||
if correct[k] == 'correct' else 0)
|
||||
correct_map.set(idset[k], correct[k], msg=messages[k],
|
||||
npoints=npoints)
|
||||
return correct_map
|
||||
@@ -1188,6 +1226,22 @@ def sympy_check2():
|
||||
return {self.answer_ids[0]: self.expect}
|
||||
return self.default_answer_map
|
||||
|
||||
def _handle_exec_exception(self, err):
|
||||
'''
|
||||
Handle an exception raised during the execution of
|
||||
custom Python code.
|
||||
|
||||
Raises a ResponseError
|
||||
'''
|
||||
|
||||
# Log the error if we are debugging
|
||||
msg = 'Error occurred while evaluating CustomResponse'
|
||||
log.warning(msg, exc_info=True)
|
||||
|
||||
# Notify student with a student input error
|
||||
_, _, traceback_obj = sys.exc_info()
|
||||
raise ResponseError, err.message, traceback_obj
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1232,8 +1286,9 @@ class CodeResponse(LoncapaResponse):
|
||||
|
||||
Expects 'xqueue' dict in ModuleSystem with the following keys that are needed by CodeResponse:
|
||||
system.xqueue = { 'interface': XqueueInterface object,
|
||||
'callback_url': Per-StudentModule callback URL
|
||||
where results are posted (string),
|
||||
'construct_callback': Per-StudentModule callback URL
|
||||
constructor, defaults to using 'score_update'
|
||||
as the correct dispatch (function),
|
||||
'default_queuename': Default queuename to submit request (string)
|
||||
}
|
||||
|
||||
@@ -1242,7 +1297,7 @@ class CodeResponse(LoncapaResponse):
|
||||
"""
|
||||
|
||||
response_tag = 'coderesponse'
|
||||
allowed_inputfields = ['textbox', 'filesubmission']
|
||||
allowed_inputfields = ['textbox', 'filesubmission', 'matlabinput']
|
||||
max_inputfields = 1
|
||||
|
||||
def setup_response(self):
|
||||
@@ -1263,7 +1318,8 @@ class CodeResponse(LoncapaResponse):
|
||||
self.queue_name = xml.get('queuename', default_queuename)
|
||||
|
||||
# VS[compat]:
|
||||
# Check if XML uses the ExternalResponse format or the generic CodeResponse format
|
||||
# Check if XML uses the ExternalResponse format or the generic
|
||||
# CodeResponse format
|
||||
codeparam = self.xml.find('codeparam')
|
||||
if codeparam is None:
|
||||
self._parse_externalresponse_xml()
|
||||
@@ -1277,12 +1333,14 @@ class CodeResponse(LoncapaResponse):
|
||||
self.answer (an answer to display to the student in the LMS)
|
||||
self.payload
|
||||
'''
|
||||
# Note that CodeResponse is agnostic to the specific contents of grader_payload
|
||||
# Note that CodeResponse is agnostic to the specific contents of
|
||||
# grader_payload
|
||||
grader_payload = codeparam.find('grader_payload')
|
||||
grader_payload = grader_payload.text if grader_payload is not None else ''
|
||||
self.payload = {'grader_payload': grader_payload}
|
||||
|
||||
self.initial_display = find_with_default(codeparam, 'initial_display', '')
|
||||
self.initial_display = find_with_default(
|
||||
codeparam, 'initial_display', '')
|
||||
self.answer = find_with_default(codeparam, 'answer_display',
|
||||
'No answer provided.')
|
||||
|
||||
@@ -1304,8 +1362,10 @@ class CodeResponse(LoncapaResponse):
|
||||
else: # no <answer> stanza; get code from <script>
|
||||
code = self.context['script_code']
|
||||
if not code:
|
||||
msg = '%s: Missing answer script code for coderesponse' % unicode(self)
|
||||
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>')
|
||||
msg = '%s: Missing answer script code for coderesponse' % unicode(
|
||||
self)
|
||||
msg += "\nSee XML source line %s" % getattr(
|
||||
self.xml, 'sourceline', '<unavailable>')
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
tests = self.xml.get('tests')
|
||||
@@ -1320,7 +1380,8 @@ class CodeResponse(LoncapaResponse):
|
||||
try:
|
||||
exec(code, penv, penv)
|
||||
except Exception as err:
|
||||
log.error('Error in CodeResponse %s: Error in problem reference code' % err)
|
||||
log.error(
|
||||
'Error in CodeResponse %s: Error in problem reference code' % err)
|
||||
raise Exception(err)
|
||||
try:
|
||||
self.answer = penv['answer']
|
||||
@@ -1333,7 +1394,7 @@ class CodeResponse(LoncapaResponse):
|
||||
# Finally, make the ExternalResponse input XML format conform to the generic
|
||||
# exteral grader interface
|
||||
# The XML tagging of grader_payload is pyxserver-specific
|
||||
grader_payload = '<pyxserver>'
|
||||
grader_payload = '<pyxserver>'
|
||||
grader_payload += '<tests>' + tests + '</tests>\n'
|
||||
grader_payload += '<processor>' + code + '</processor>'
|
||||
grader_payload += '</pyxserver>'
|
||||
@@ -1346,14 +1407,14 @@ class CodeResponse(LoncapaResponse):
|
||||
except Exception as err:
|
||||
log.error('Error in CodeResponse %s: cannot get student answer for %s;'
|
||||
' student_answers=%s' %
|
||||
(err, self.answer_id, convert_files_to_filenames(student_answers)))
|
||||
(err, self.answer_id, convert_files_to_filenames(student_answers)))
|
||||
raise Exception(err)
|
||||
|
||||
# We do not support xqueue within Studio.
|
||||
if self.system.xqueue is None:
|
||||
cmap = CorrectMap()
|
||||
cmap.set(self.answer_id, queuestate=None,
|
||||
msg='Error checking problem: no external queueing server is configured.')
|
||||
msg='Error checking problem: no external queueing server is configured.')
|
||||
return cmap
|
||||
|
||||
# Prepare xqueue request
|
||||
@@ -1368,9 +1429,11 @@ class CodeResponse(LoncapaResponse):
|
||||
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
|
||||
anonymous_student_id +
|
||||
self.answer_id)
|
||||
xheader = xqueue_interface.make_xheader(lms_callback_url=self.system.xqueue['callback_url'],
|
||||
lms_key=queuekey,
|
||||
queue_name=self.queue_name)
|
||||
callback_url = self.system.xqueue['construct_callback']()
|
||||
xheader = xqueue_interface.make_xheader(
|
||||
lms_callback_url=callback_url,
|
||||
lms_key=queuekey,
|
||||
queue_name=self.queue_name)
|
||||
|
||||
# Generate body
|
||||
if is_list_of_files(submission):
|
||||
@@ -1381,13 +1444,16 @@ class CodeResponse(LoncapaResponse):
|
||||
|
||||
contents = self.payload.copy()
|
||||
|
||||
# Metadata related to the student submission revealed to the external grader
|
||||
# Metadata related to the student submission revealed to the external
|
||||
# grader
|
||||
student_info = {'anonymous_student_id': anonymous_student_id,
|
||||
'submission_time': qtime,
|
||||
}
|
||||
}
|
||||
contents.update({'student_info': json.dumps(student_info)})
|
||||
|
||||
# Submit request. When successful, 'msg' is the prior length of the queue
|
||||
# Submit request. When successful, 'msg' is the prior length of the
|
||||
# queue
|
||||
|
||||
if is_list_of_files(submission):
|
||||
# TODO: Is there any information we want to send here?
|
||||
contents.update({'student_response': ''})
|
||||
@@ -1415,13 +1481,15 @@ class CodeResponse(LoncapaResponse):
|
||||
# 2) Frontend: correctness='incomplete' eventually trickles down
|
||||
# through inputtypes.textbox and .filesubmission to inform the
|
||||
# browser to poll the LMS
|
||||
cmap.set(self.answer_id, queuestate=queuestate, correctness='incomplete', msg=msg)
|
||||
cmap.set(self.answer_id, queuestate=queuestate,
|
||||
correctness='incomplete', msg=msg)
|
||||
|
||||
return cmap
|
||||
|
||||
def update_score(self, score_msg, oldcmap, queuekey):
|
||||
|
||||
(valid_score_msg, correct, points, msg) = self._parse_score_msg(score_msg)
|
||||
(valid_score_msg, correct, points,
|
||||
msg) = self._parse_score_msg(score_msg)
|
||||
if not valid_score_msg:
|
||||
oldcmap.set(self.answer_id,
|
||||
msg='Invalid grader reply. Please contact the course staff.')
|
||||
@@ -1433,14 +1501,16 @@ class CodeResponse(LoncapaResponse):
|
||||
self.context['correct'] = correctness
|
||||
|
||||
# Replace 'oldcmap' with new grading results if queuekey matches. If queuekey
|
||||
# does not match, we keep waiting for the score_msg whose key actually matches
|
||||
# does not match, we keep waiting for the score_msg whose key actually
|
||||
# matches
|
||||
if oldcmap.is_right_queuekey(self.answer_id, queuekey):
|
||||
# Sanity check on returned points
|
||||
if points < 0:
|
||||
points = 0
|
||||
# Queuestate is consumed
|
||||
oldcmap.set(self.answer_id, npoints=points, correctness=correctness,
|
||||
msg=msg.replace(' ', ' '), queuestate=None)
|
||||
oldcmap.set(
|
||||
self.answer_id, npoints=points, correctness=correctness,
|
||||
msg=msg.replace(' ', ' '), queuestate=None)
|
||||
else:
|
||||
log.debug('CodeResponse: queuekey %s does not match for answer_id=%s.' %
|
||||
(queuekey, self.answer_id))
|
||||
@@ -1560,15 +1630,18 @@ main()
|
||||
if answer is not None:
|
||||
answer_src = answer.get('src')
|
||||
if answer_src is not None:
|
||||
self.code = self.system.filesystem.open('src/' + answer_src).read()
|
||||
self.code = self.system.filesystem.open(
|
||||
'src/' + answer_src).read()
|
||||
else:
|
||||
self.code = answer.text
|
||||
else:
|
||||
# no <answer> stanza; get code from <script>
|
||||
self.code = self.context['script_code']
|
||||
if not self.code:
|
||||
msg = '%s: Missing answer script code for externalresponse' % unicode(self)
|
||||
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>')
|
||||
msg = '%s: Missing answer script code for externalresponse' % unicode(
|
||||
self)
|
||||
msg += "\nSee XML source line %s" % getattr(
|
||||
self.xml, 'sourceline', '<unavailable>')
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
self.tests = xml.get('tests')
|
||||
@@ -1591,10 +1664,12 @@ main()
|
||||
payload.update(extra_payload)
|
||||
|
||||
try:
|
||||
# call external server. TODO: synchronous call, can block for a long time
|
||||
# call external server. TODO: synchronous call, can block for a
|
||||
# long time
|
||||
r = requests.post(self.url, data=payload)
|
||||
except Exception as err:
|
||||
msg = 'Error %s - cannot connect to external server url=%s' % (err, self.url)
|
||||
msg = 'Error %s - cannot connect to external server url=%s' % (
|
||||
err, self.url)
|
||||
log.error(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
@@ -1602,13 +1677,15 @@ main()
|
||||
log.info('response = %s' % r.text)
|
||||
|
||||
if (not r.text) or (not r.text.strip()):
|
||||
raise Exception('Error: no response from external server url=%s' % self.url)
|
||||
raise Exception(
|
||||
'Error: no response from external server url=%s' % self.url)
|
||||
|
||||
try:
|
||||
# response is XML; parse it
|
||||
rxml = etree.fromstring(r.text)
|
||||
except Exception as err:
|
||||
msg = 'Error %s - cannot parse response from external server r.text=%s' % (err, r.text)
|
||||
msg = 'Error %s - cannot parse response from external server r.text=%s' % (
|
||||
err, r.text)
|
||||
log.error(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
@@ -1633,7 +1710,8 @@ main()
|
||||
except Exception as err:
|
||||
log.error('Error %s' % err)
|
||||
if self.system.DEBUG:
|
||||
cmap.set_dict(dict(zip(sorted(self.answer_ids), ['incorrect'] * len(idset))))
|
||||
cmap.set_dict(dict(zip(sorted(
|
||||
self.answer_ids), ['incorrect'] * len(idset))))
|
||||
cmap.set_property(
|
||||
self.answer_ids[0], 'msg',
|
||||
'<span class="inline-error">%s</span>' % str(err).replace('<', '<'))
|
||||
@@ -1650,7 +1728,8 @@ main()
|
||||
# create CorrectMap
|
||||
for key in idset:
|
||||
idx = idset.index(key)
|
||||
msg = rxml.find('message').text.replace(' ', ' ') if idx == 0 else None
|
||||
msg = rxml.find('message').text.replace(
|
||||
' ', ' ') if idx == 0 else None
|
||||
cmap.set(key, self.context['correct'][idx], msg=msg)
|
||||
|
||||
return cmap
|
||||
@@ -1665,7 +1744,8 @@ main()
|
||||
except Exception as err:
|
||||
log.error('Error %s' % err)
|
||||
if self.system.DEBUG:
|
||||
msg = '<span class="inline-error">%s</span>' % str(err).replace('<', '<')
|
||||
msg = '<span class="inline-error">%s</span>' % str(
|
||||
err).replace('<', '<')
|
||||
exans = [''] * len(self.answer_ids)
|
||||
exans[0] = msg
|
||||
|
||||
@@ -1712,8 +1792,9 @@ class FormulaResponse(LoncapaResponse):
|
||||
self.correct_answer = contextualize_text(xml.get('answer'), context)
|
||||
self.samples = contextualize_text(xml.get('samples'), context)
|
||||
try:
|
||||
self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default',
|
||||
id=xml.get('id'))[0]
|
||||
self.tolerance_xml = xml.xpath(
|
||||
'//*[@id=$id]//responseparam[@type="tolerance"]/@default',
|
||||
id=xml.get('id'))[0]
|
||||
self.tolerance = contextualize_text(self.tolerance_xml, context)
|
||||
except Exception:
|
||||
self.tolerance = '0.00001'
|
||||
@@ -1735,14 +1816,15 @@ class FormulaResponse(LoncapaResponse):
|
||||
|
||||
def get_score(self, student_answers):
|
||||
given = student_answers[self.answer_id]
|
||||
correctness = self.check_formula(self.correct_answer, given, self.samples)
|
||||
correctness = self.check_formula(
|
||||
self.correct_answer, given, self.samples)
|
||||
return CorrectMap(self.answer_id, correctness)
|
||||
|
||||
def check_formula(self, expected, given, samples):
|
||||
variables = samples.split('@')[0].split(',')
|
||||
numsamples = int(samples.split('@')[1].split('#')[1])
|
||||
sranges = zip(*map(lambda x: map(float, x.split(",")),
|
||||
samples.split('@')[1].split('#')[0].split(':')))
|
||||
samples.split('@')[1].split('#')[0].split(':')))
|
||||
|
||||
ranges = dict(zip(variables, sranges))
|
||||
for i in range(numsamples):
|
||||
@@ -1753,22 +1835,26 @@ class FormulaResponse(LoncapaResponse):
|
||||
value = random.uniform(*ranges[var])
|
||||
instructor_variables[str(var)] = value
|
||||
student_variables[str(var)] = value
|
||||
#log.debug('formula: instructor_vars=%s, expected=%s' % (instructor_variables,expected))
|
||||
# log.debug('formula: instructor_vars=%s, expected=%s' %
|
||||
# (instructor_variables,expected))
|
||||
instructor_result = evaluator(instructor_variables, dict(),
|
||||
expected, cs=self.case_sensitive)
|
||||
try:
|
||||
#log.debug('formula: student_vars=%s, given=%s' % (student_variables,given))
|
||||
# log.debug('formula: student_vars=%s, given=%s' %
|
||||
# (student_variables,given))
|
||||
student_result = evaluator(student_variables,
|
||||
dict(),
|
||||
given,
|
||||
cs=self.case_sensitive)
|
||||
except UndefinedVariable as uv:
|
||||
log.debug('formularesponse: undefined variable in given=%s' % given)
|
||||
raise StudentInputError("Invalid input: " + uv.message + " not permitted in answer")
|
||||
log.debug(
|
||||
'formularesponse: undefined variable in given=%s' % given)
|
||||
raise StudentInputError(
|
||||
"Invalid input: " + uv.message + " not permitted in answer")
|
||||
except Exception as err:
|
||||
#traceback.print_exc()
|
||||
# traceback.print_exc()
|
||||
log.debug('formularesponse: error %s in formula' % err)
|
||||
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %\
|
||||
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %
|
||||
cgi.escape(given))
|
||||
if numpy.isnan(student_result) or numpy.isinf(student_result):
|
||||
return "incorrect"
|
||||
@@ -1792,9 +1878,11 @@ class FormulaResponse(LoncapaResponse):
|
||||
for hxml in hxml_set:
|
||||
samples = hxml.get('samples')
|
||||
name = hxml.get('name')
|
||||
correct_answer = contextualize_text(hxml.get('answer'), self.context)
|
||||
correct_answer = contextualize_text(
|
||||
hxml.get('answer'), self.context)
|
||||
try:
|
||||
correctness = self.check_formula(correct_answer, given, samples)
|
||||
correctness = self.check_formula(
|
||||
correct_answer, given, samples)
|
||||
except Exception:
|
||||
correctness = 'incorrect'
|
||||
if correctness == 'correct':
|
||||
@@ -1825,11 +1913,20 @@ class SchematicResponse(LoncapaResponse):
|
||||
|
||||
def get_score(self, student_answers):
|
||||
from capa_problem import global_context
|
||||
submission = [json.loads(student_answers[k]) for k in sorted(self.answer_ids)]
|
||||
submission = [json.loads(student_answers[
|
||||
k]) for k in sorted(self.answer_ids)]
|
||||
self.context.update({'submission': submission})
|
||||
exec self.code in global_context, self.context
|
||||
|
||||
try:
|
||||
exec self.code in global_context, self.context
|
||||
|
||||
except Exception as err:
|
||||
_, _, traceback_obj = sys.exc_info()
|
||||
raise ResponseError, ResponseError(err.message), traceback_obj
|
||||
|
||||
cmap = CorrectMap()
|
||||
cmap.set_dict(dict(zip(sorted(self.answer_ids), self.context['correct'])))
|
||||
cmap.set_dict(dict(zip(sorted(
|
||||
self.answer_ids), self.context['correct'])))
|
||||
return cmap
|
||||
|
||||
def get_answers(self):
|
||||
@@ -1886,17 +1983,20 @@ class ImageResponse(LoncapaResponse):
|
||||
self.ielements = self.inputfields
|
||||
self.answer_ids = [ie.get('id') for ie in self.ielements]
|
||||
|
||||
|
||||
def get_score(self, student_answers):
|
||||
correct_map = CorrectMap()
|
||||
expectedset = self.get_answers()
|
||||
expectedset = self.get_mapped_answers()
|
||||
for aid in self.answer_ids: # loop through IDs of <imageinput>
|
||||
# fields in our stanza
|
||||
given = student_answers[aid] # this should be a string of the form '[x,y]'
|
||||
given = student_answers[
|
||||
aid] # this should be a string of the form '[x,y]'
|
||||
correct_map.set(aid, 'incorrect')
|
||||
if not given: # No answer to parse. Mark as incorrect and move on
|
||||
continue
|
||||
# parse given answer
|
||||
m = re.match('\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
|
||||
m = re.match(
|
||||
'\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
|
||||
if not m:
|
||||
raise Exception('[capamodule.capa.responsetypes.imageinput] '
|
||||
'error grading %s (input=%s)' % (aid, given))
|
||||
@@ -1904,20 +2004,24 @@ class ImageResponse(LoncapaResponse):
|
||||
|
||||
rectangles, regions = expectedset
|
||||
if rectangles[aid]: # rectangles part - for backward compatibility
|
||||
# Check whether given point lies in any of the solution rectangles
|
||||
# Check whether given point lies in any of the solution
|
||||
# rectangles
|
||||
solution_rectangles = rectangles[aid].split(';')
|
||||
for solution_rectangle in solution_rectangles:
|
||||
# parse expected answer
|
||||
# TODO: Compile regexp on file load
|
||||
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
|
||||
solution_rectangle.strip().replace(' ', ''))
|
||||
m = re.match(
|
||||
'[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
|
||||
solution_rectangle.strip().replace(' ', ''))
|
||||
if not m:
|
||||
msg = 'Error in problem specification! cannot parse rectangle in %s' % (
|
||||
etree.tostring(self.ielements[aid], pretty_print=True))
|
||||
raise Exception('[capamodule.capa.responsetypes.imageinput] ' + msg)
|
||||
raise Exception(
|
||||
'[capamodule.capa.responsetypes.imageinput] ' + msg)
|
||||
(llx, lly, urx, ury) = [int(x) for x in m.groups()]
|
||||
|
||||
# answer is correct if (x,y) is within the specified rectangle
|
||||
# answer is correct if (x,y) is within the specified
|
||||
# rectangle
|
||||
if (llx <= gx <= urx) and (lly <= gy <= ury):
|
||||
correct_map.set(aid, 'correct')
|
||||
break
|
||||
@@ -1937,11 +2041,45 @@ class ImageResponse(LoncapaResponse):
|
||||
break
|
||||
return correct_map
|
||||
|
||||
def get_mapped_answers(self):
|
||||
'''
|
||||
Returns the internal representation of the answers
|
||||
|
||||
Input:
|
||||
None
|
||||
Returns:
|
||||
tuple (dict, dict) -
|
||||
rectangles (dict) - a map of inputs to the defined rectangle for that input
|
||||
regions (dict) - a map of inputs to the defined region for that input
|
||||
'''
|
||||
answers = (
|
||||
dict([(ie.get('id'), ie.get(
|
||||
'rectangle')) for ie in self.ielements]),
|
||||
dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements]))
|
||||
return answers
|
||||
|
||||
def get_answers(self):
|
||||
return (dict([(ie.get('id'), ie.get('rectangle')) for ie in self.ielements]),
|
||||
dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements]))
|
||||
'''
|
||||
Returns the external representation of the answers
|
||||
|
||||
Input:
|
||||
None
|
||||
Returns:
|
||||
dict (str, (str, str)) - a map of inputs to a tuple of their rectange
|
||||
and their regions
|
||||
'''
|
||||
answers = {}
|
||||
for ie in self.ielements:
|
||||
ie_id = ie.get('id')
|
||||
answers[ie_id] = (ie.get('rectangle'), ie.get('regions'))
|
||||
|
||||
return answers
|
||||
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AnnotationResponse(LoncapaResponse):
|
||||
'''
|
||||
Checking of annotation responses.
|
||||
@@ -1952,7 +2090,8 @@ class AnnotationResponse(LoncapaResponse):
|
||||
response_tag = 'annotationresponse'
|
||||
allowed_inputfields = ['annotationinput']
|
||||
max_inputfields = 1
|
||||
default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2 }
|
||||
default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2}
|
||||
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
self.scoring_map = self._get_scoring_map()
|
||||
@@ -1966,7 +2105,8 @@ class AnnotationResponse(LoncapaResponse):
|
||||
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()
|
||||
is_valid = student_option is not None and student_option in scoring.keys(
|
||||
)
|
||||
|
||||
(correctness, points) = ('incorrect', None)
|
||||
if is_valid:
|
||||
@@ -1981,14 +2121,14 @@ class AnnotationResponse(LoncapaResponse):
|
||||
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])
|
||||
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) ])
|
||||
}) for option in self._find_options(inputfield)])
|
||||
|
||||
scoring_map[inputfield.get('id')] = option_scoring
|
||||
|
||||
@@ -1998,9 +2138,11 @@ class AnnotationResponse(LoncapaResponse):
|
||||
''' Returns a dict of answers for each input.'''
|
||||
answer_map = {}
|
||||
for inputfield in self.inputfields:
|
||||
correct_option = self._find_option_with_choice(inputfield, 'correct')
|
||||
correct_option = self._find_option_with_choice(
|
||||
inputfield, 'correct')
|
||||
if correct_option is not None:
|
||||
answer_map[inputfield.get('id')] = correct_option.get('description')
|
||||
input_id = inputfield.get('id')
|
||||
answer_map[input_id] = correct_option.get('description')
|
||||
return answer_map
|
||||
|
||||
def _get_max_points(self):
|
||||
@@ -2016,7 +2158,7 @@ class AnnotationResponse(LoncapaResponse):
|
||||
'id': index,
|
||||
'description': option.text,
|
||||
'choice': option.get('choice')
|
||||
} for (index, option) in enumerate(elements) ]
|
||||
} for (index, option) in enumerate(elements)]
|
||||
|
||||
def _find_option_with_choice(self, inputfield, choice):
|
||||
''' Returns the option with the given choice value, otherwise None. '''
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
% for choice_id, choice_description in choices:
|
||||
<label for="input_${id}_${choice_id}"
|
||||
% if input_type == 'radio' and choice_id in value:
|
||||
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
|
||||
<%
|
||||
if status == 'correct':
|
||||
correctness = 'correct'
|
||||
@@ -30,10 +30,12 @@
|
||||
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:
|
||||
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
|
||||
checked="true"
|
||||
% elif input_type != 'radio' and choice_id in value:
|
||||
checked="true"
|
||||
% endif
|
||||
|
||||
/> ${choice_description} </label>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
% endif
|
||||
|
||||
<div id="genex_container"></div>
|
||||
<input type="hidden" name="dna_sequence" id="dna_sequence" value ="${dna_sequence}"></input>
|
||||
<input type="hidden" name="genex_dna_sequence" id="genex_dna_sequence" value ="${genex_dna_sequence}"></input>
|
||||
<input type="hidden" name="genex_problem_number" id="genex_problem_number" value ="${genex_problem_number}"></input>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
|
||||
117
common/lib/capa/capa/templates/matlabinput.html
Normal file
117
common/lib/capa/capa/templates/matlabinput.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<section id="textbox_${id}" class="textbox">
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" id="input_${id}"
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
>${value|h}</textarea>
|
||||
|
||||
<div class="grader-status">
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}">Correct</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}">Incorrect</span>
|
||||
% elif status == 'queued':
|
||||
<span class="processing" id="status_${id}">Queued</span>
|
||||
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
|
||||
% endif
|
||||
|
||||
% if hidden:
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
|
||||
<p class="debug">${status}</p>
|
||||
</div>
|
||||
|
||||
<span id="answer_${id}"></span>
|
||||
|
||||
<div class="external-grader-message">
|
||||
${msg|n}
|
||||
</div>
|
||||
<div class="external-grader-message">
|
||||
${queue_msg|n}
|
||||
</div>
|
||||
|
||||
<div class="plot-button">
|
||||
<input type="button" class="save" name="plot-button" id="plot_${id}" value="Plot" />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Note: We need to make the area follow the CodeMirror for this to work.
|
||||
$(function(){
|
||||
var cm = CodeMirror.fromTextArea(document.getElementById("input_${id}"), {
|
||||
% if linenumbers == 'true':
|
||||
lineNumbers: true,
|
||||
% endif
|
||||
mode: "matlab",
|
||||
matchBrackets: true,
|
||||
lineWrapping: true,
|
||||
indentUnit: "${tabsize}",
|
||||
tabSize: "${tabsize}",
|
||||
indentWithTabs: false,
|
||||
extraKeys: {
|
||||
"Tab": function(cm) {
|
||||
cm.replaceSelection("${' '*tabsize}", "end");
|
||||
}
|
||||
},
|
||||
smartIndent: false
|
||||
});
|
||||
|
||||
$("#textbox_${id}").find('.CodeMirror-scroll').height(${int(13.5*eval(rows))});
|
||||
|
||||
var gentle_alert = function (parent_elt, msg) {
|
||||
if($(parent_elt).find('.capa_alert').length) {
|
||||
$(parent_elt).find('.capa_alert').remove();
|
||||
}
|
||||
var alert_elem = "<div>" + msg + "</div>";
|
||||
alert_elem = $(alert_elem).addClass('capa_alert');
|
||||
$(parent_elt).find('.action').after(alert_elem);
|
||||
$(parent_elt).find('.capa_alert').css({opacity: 0}).animate({opacity: 1}, 700);
|
||||
}
|
||||
|
||||
|
||||
// hook up the plot button
|
||||
var plot = function(event) {
|
||||
var problem_elt = $(event.target).closest('.problems-wrapper');
|
||||
url = $(event.target).closest('.problems-wrapper').data('url');
|
||||
input_id = "${id}";
|
||||
|
||||
// save the codemirror text to the textarea
|
||||
cm.save();
|
||||
var input = $("#input_${id}");
|
||||
// pull out the coded text
|
||||
submission = input.val();
|
||||
|
||||
answer = input.serialize();
|
||||
|
||||
// setup callback for after we send information to plot
|
||||
var plot_callback = function(response) {
|
||||
if(response.success) {
|
||||
window.location.reload();
|
||||
}
|
||||
else {
|
||||
gentle_alert(problem_elt, msg);
|
||||
}
|
||||
}
|
||||
|
||||
var save_callback = function(response) {
|
||||
if(response.success) {
|
||||
// send information to the problem's plot functionality
|
||||
Problem.inputAjax(url, input_id, 'plot',
|
||||
{'submission': submission}, plot_callback);
|
||||
}
|
||||
else {
|
||||
gentle_alert(problem_elt, msg);
|
||||
}
|
||||
}
|
||||
|
||||
// save the answer
|
||||
$.postWithPrefix(url + '/problem_save', answer, save_callback);
|
||||
|
||||
}
|
||||
$('#plot_${id}').click(plot);
|
||||
|
||||
});
|
||||
</script>
|
||||
</section>
|
||||
@@ -2,7 +2,7 @@ import fs
|
||||
import fs.osfs
|
||||
import os
|
||||
|
||||
from mock import Mock
|
||||
from mock import Mock, MagicMock
|
||||
|
||||
import xml.sax.saxutils as saxutils
|
||||
|
||||
@@ -16,6 +16,11 @@ def tst_render_template(template, context):
|
||||
"""
|
||||
return '<div>{0}</div>'.format(saxutils.escape(repr(context)))
|
||||
|
||||
def calledback_url(dispatch = 'score_update'):
|
||||
return dispatch
|
||||
|
||||
xqueue_interface = MagicMock()
|
||||
xqueue_interface.send_to_queue.return_value = (0, 'Success!')
|
||||
|
||||
test_system = Mock(
|
||||
ajax_url='courses/course_id/modx/a_location',
|
||||
@@ -26,7 +31,7 @@ test_system = Mock(
|
||||
user=Mock(),
|
||||
filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")),
|
||||
debug=True,
|
||||
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
xqueue={'interface': xqueue_interface, 'construct_callback': calledback_url, 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
|
||||
anonymous_student_id='student'
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import xml.sax.saxutils as saxutils
|
||||
|
||||
from . import test_system
|
||||
from capa import inputtypes
|
||||
from mock import ANY
|
||||
|
||||
# just a handy shortcut
|
||||
lookup_tag = inputtypes.registry.get_class_for_tag
|
||||
@@ -300,6 +301,98 @@ class CodeInputTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
class MatlabTest(unittest.TestCase):
|
||||
'''
|
||||
Test Matlab input types
|
||||
'''
|
||||
def setUp(self):
|
||||
self.rows = '10'
|
||||
self.cols = '80'
|
||||
self.tabsize = '4'
|
||||
self.mode = ""
|
||||
self.payload = "payload"
|
||||
self.linenumbers = 'true'
|
||||
self.xml = """<matlabinput id="prob_1_2"
|
||||
rows="{r}" cols="{c}"
|
||||
tabsize="{tabsize}" mode="{m}"
|
||||
linenumbers="{ln}">
|
||||
<plot_payload>
|
||||
{payload}
|
||||
</plot_payload>
|
||||
</matlabinput>""".format(r = self.rows,
|
||||
c = self.cols,
|
||||
tabsize = self.tabsize,
|
||||
m = self.mode,
|
||||
payload = self.payload,
|
||||
ln = self.linenumbers)
|
||||
elt = etree.fromstring(self.xml)
|
||||
state = {'value': 'print "good evening"',
|
||||
'status': 'incomplete',
|
||||
'feedback': {'message': '3'}, }
|
||||
|
||||
self.input_class = lookup_tag('matlabinput')
|
||||
self.the_input = self.input_class(test_system, elt, state)
|
||||
|
||||
|
||||
def test_rendering(self):
|
||||
context = self.the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': 'print "good evening"',
|
||||
'status': 'queued',
|
||||
'msg': self.input_class.submitted_msg,
|
||||
'mode': self.mode,
|
||||
'rows': self.rows,
|
||||
'cols': self.cols,
|
||||
'queue_msg': '',
|
||||
'linenumbers': 'true',
|
||||
'hidden': '',
|
||||
'tabsize': int(self.tabsize),
|
||||
'queue_len': '3',
|
||||
}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
def test_rendering_with_state(self):
|
||||
state = {'value': 'print "good evening"',
|
||||
'status': 'incomplete',
|
||||
'input_state': {'queue_msg': 'message'},
|
||||
'feedback': {'message': '3'}, }
|
||||
elt = etree.fromstring(self.xml)
|
||||
|
||||
input_class = lookup_tag('matlabinput')
|
||||
the_input = self.input_class(test_system, elt, state)
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': 'print "good evening"',
|
||||
'status': 'queued',
|
||||
'msg': self.input_class.submitted_msg,
|
||||
'mode': self.mode,
|
||||
'rows': self.rows,
|
||||
'cols': self.cols,
|
||||
'queue_msg': 'message',
|
||||
'linenumbers': 'true',
|
||||
'hidden': '',
|
||||
'tabsize': int(self.tabsize),
|
||||
'queue_len': '3',
|
||||
}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
def test_plot_data(self):
|
||||
get = {'submission': 'x = 1234;'}
|
||||
response = self.the_input.handle_ajax("plot", get)
|
||||
|
||||
test_system.xqueue['interface'].send_to_queue.assert_called_with(header=ANY, body=ANY)
|
||||
|
||||
self.assertTrue(response['success'])
|
||||
self.assertTrue(self.the_input.input_state['queuekey'] is not None)
|
||||
self.assertEqual(self.the_input.input_state['queuestate'], 'queued')
|
||||
|
||||
|
||||
|
||||
|
||||
class SchematicTest(unittest.TestCase):
|
||||
'''
|
||||
|
||||
@@ -13,10 +13,13 @@ import textwrap
|
||||
from . import test_system
|
||||
|
||||
import capa.capa_problem as lcp
|
||||
from capa.responsetypes import LoncapaProblemError, \
|
||||
StudentInputError, ResponseError
|
||||
from capa.correctmap import CorrectMap
|
||||
from capa.util import convert_files_to_filenames
|
||||
from capa.xqueue_interface import dateformat
|
||||
|
||||
|
||||
class ResponseTest(unittest.TestCase):
|
||||
""" Base class for tests of capa responses."""
|
||||
|
||||
@@ -35,16 +38,21 @@ class ResponseTest(unittest.TestCase):
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_correctness('1_2_1'), expected_correctness)
|
||||
|
||||
def assert_answer_format(self, problem):
|
||||
answers = problem.get_question_answers()
|
||||
self.assertTrue(answers['1_2_1'] is not None)
|
||||
|
||||
def assert_multiple_grade(self, problem, correct_answers, incorrect_answers):
|
||||
for input_str in correct_answers:
|
||||
result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1')
|
||||
self.assertEqual(result, 'correct',
|
||||
msg="%s should be marked correct" % str(input_str))
|
||||
msg="%s should be marked correct" % str(input_str))
|
||||
|
||||
for input_str in incorrect_answers:
|
||||
result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1')
|
||||
self.assertEqual(result, 'incorrect',
|
||||
msg="%s should be marked incorrect" % str(input_str))
|
||||
msg="%s should be marked incorrect" % str(input_str))
|
||||
|
||||
|
||||
class MultiChoiceResponseTest(ResponseTest):
|
||||
from response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||
@@ -60,7 +68,7 @@ class MultiChoiceResponseTest(ResponseTest):
|
||||
|
||||
def test_named_multiple_choice_grade(self):
|
||||
problem = self.build_problem(choices=[False, True, False],
|
||||
choice_names=["foil_1", "foil_2", "foil_3"])
|
||||
choice_names=["foil_1", "foil_2", "foil_3"])
|
||||
|
||||
# Ensure that we get the expected grades
|
||||
self.assert_grade(problem, 'choice_foil_1', 'incorrect')
|
||||
@@ -91,7 +99,7 @@ class TrueFalseResponseTest(ResponseTest):
|
||||
|
||||
def test_named_true_false_grade(self):
|
||||
problem = self.build_problem(choices=[False, True, True],
|
||||
choice_names=['foil_1','foil_2','foil_3'])
|
||||
choice_names=['foil_1', 'foil_2', 'foil_3'])
|
||||
|
||||
# Check the results
|
||||
# Mark correct if and only if ALL (and only) correct chocies selected
|
||||
@@ -107,6 +115,7 @@ class TrueFalseResponseTest(ResponseTest):
|
||||
self.assert_grade(problem, 'choice_foil_4', 'incorrect')
|
||||
self.assert_grade(problem, 'not_a_choice', 'incorrect')
|
||||
|
||||
|
||||
class ImageResponseTest(ResponseTest):
|
||||
from response_xml_factory import ImageResponseXMLFactory
|
||||
xml_factory_class = ImageResponseXMLFactory
|
||||
@@ -118,7 +127,7 @@ class ImageResponseTest(ResponseTest):
|
||||
# Anything inside the rectangle (and along the borders) is correct
|
||||
# Everything else is incorrect
|
||||
correct_inputs = ["[12,19]", "[10,10]", "[20,20]",
|
||||
"[10,15]", "[20,15]", "[15,10]", "[15,20]"]
|
||||
"[10,15]", "[20,15]", "[15,10]", "[15,20]"]
|
||||
incorrect_inputs = ["[4,6]", "[25,15]", "[15,40]", "[15,4]"]
|
||||
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
|
||||
|
||||
@@ -145,7 +154,7 @@ class ImageResponseTest(ResponseTest):
|
||||
|
||||
def test_multiple_regions_grade(self):
|
||||
# Define multiple regions that the user can select
|
||||
region_str="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"
|
||||
region_str = "[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"
|
||||
|
||||
# Expect that only points inside the regions are marked correct
|
||||
problem = self.build_problem(regions=region_str)
|
||||
@@ -155,7 +164,7 @@ class ImageResponseTest(ResponseTest):
|
||||
|
||||
def test_region_and_rectangle_grade(self):
|
||||
rectangle_str = "(100,100)-(200,200)"
|
||||
region_str="[[10,10], [20,10], [20, 30]]"
|
||||
region_str = "[[10,10], [20,10], [20, 30]]"
|
||||
|
||||
# Expect that only points inside the rectangle or region are marked correct
|
||||
problem = self.build_problem(regions=region_str, rectangle=rectangle_str)
|
||||
@@ -163,6 +172,13 @@ class ImageResponseTest(ResponseTest):
|
||||
incorrect_inputs = ["[0,0]", "[600,300]"]
|
||||
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
|
||||
|
||||
def test_show_answer(self):
|
||||
rectangle_str = "(100,100)-(200,200)"
|
||||
region_str = "[[10,10], [20,10], [20, 30]]"
|
||||
|
||||
problem = self.build_problem(regions=region_str, rectangle=rectangle_str)
|
||||
self.assert_answer_format(problem)
|
||||
|
||||
|
||||
class SymbolicResponseTest(unittest.TestCase):
|
||||
def test_sr_grade(self):
|
||||
@@ -171,85 +187,85 @@ class SymbolicResponseTest(unittest.TestCase):
|
||||
test_lcp = lcp.LoncapaProblem(open(symbolicresponse_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': 'cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]',
|
||||
'1_2_1_dynamath': '''
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<mstyle displaystyle="true">
|
||||
<mrow>
|
||||
<mi>cos</mi>
|
||||
<mrow>
|
||||
<mo>(</mo>
|
||||
<mi>θ</mi>
|
||||
<mo>)</mo>
|
||||
</mrow>
|
||||
</mrow>
|
||||
<mo>⋅</mo>
|
||||
<mrow>
|
||||
<mo>[</mo>
|
||||
<mtable>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
</mtable>
|
||||
<mo>]</mo>
|
||||
</mrow>
|
||||
<mo>+</mo>
|
||||
<mi>i</mi>
|
||||
<mo>⋅</mo>
|
||||
<mrow>
|
||||
<mi>sin</mi>
|
||||
<mrow>
|
||||
<mo>(</mo>
|
||||
<mi>θ</mi>
|
||||
<mo>)</mo>
|
||||
</mrow>
|
||||
</mrow>
|
||||
<mo>⋅</mo>
|
||||
<mrow>
|
||||
<mo>[</mo>
|
||||
<mtable>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
</mtable>
|
||||
<mo>]</mo>
|
||||
</mrow>
|
||||
</mstyle>
|
||||
</math>
|
||||
''',
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<mstyle displaystyle="true">
|
||||
<mrow>
|
||||
<mi>cos</mi>
|
||||
<mrow>
|
||||
<mo>(</mo>
|
||||
<mi>θ</mi>
|
||||
<mo>)</mo>
|
||||
</mrow>
|
||||
</mrow>
|
||||
<mo>⋅</mo>
|
||||
<mrow>
|
||||
<mo>[</mo>
|
||||
<mtable>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
</mtable>
|
||||
<mo>]</mo>
|
||||
</mrow>
|
||||
<mo>+</mo>
|
||||
<mi>i</mi>
|
||||
<mo>⋅</mo>
|
||||
<mrow>
|
||||
<mi>sin</mi>
|
||||
<mrow>
|
||||
<mo>(</mo>
|
||||
<mi>θ</mi>
|
||||
<mo>)</mo>
|
||||
</mrow>
|
||||
</mrow>
|
||||
<mo>⋅</mo>
|
||||
<mrow>
|
||||
<mo>[</mo>
|
||||
<mtable>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
</mtable>
|
||||
<mo>]</mo>
|
||||
</mrow>
|
||||
</mstyle>
|
||||
</math>
|
||||
''',
|
||||
}
|
||||
wrong_answers = {'1_2_1': '2',
|
||||
'1_2_1_dynamath': '''
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<mstyle displaystyle="true">
|
||||
<mn>2</mn>
|
||||
</mstyle>
|
||||
</math>''',
|
||||
}
|
||||
<mstyle displaystyle="true">
|
||||
<mn>2</mn>
|
||||
</mstyle>
|
||||
</math>''',
|
||||
}
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(wrong_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
|
||||
@@ -260,7 +276,7 @@ class OptionResponseTest(ResponseTest):
|
||||
|
||||
def test_grade(self):
|
||||
problem = self.build_problem(options=["first", "second", "third"],
|
||||
correct_option="second")
|
||||
correct_option="second")
|
||||
|
||||
# Assert that we get the expected grades
|
||||
self.assert_grade(problem, "first", "incorrect")
|
||||
@@ -281,9 +297,9 @@ class FormulaResponseTest(ResponseTest):
|
||||
|
||||
# The expected solution is numerically equivalent to x+2y
|
||||
problem = self.build_problem(sample_dict=sample_dict,
|
||||
num_samples=10,
|
||||
tolerance=0.01,
|
||||
answer="x+2*y")
|
||||
num_samples=10,
|
||||
tolerance=0.01,
|
||||
answer="x+2*y")
|
||||
|
||||
# Expect an equivalent formula to be marked correct
|
||||
# 2x - x + y + y = x + 2y
|
||||
@@ -297,33 +313,31 @@ class FormulaResponseTest(ResponseTest):
|
||||
|
||||
def test_hint(self):
|
||||
# Sample variables x and y in the range [-10, 10]
|
||||
sample_dict = {'x': (-10, 10), 'y': (-10,10) }
|
||||
sample_dict = {'x': (-10, 10), 'y': (-10, 10)}
|
||||
|
||||
# Give a hint if the user leaves off the coefficient
|
||||
# or leaves out x
|
||||
hints = [('x + 3*y', 'y_coefficient', 'Check the coefficient of y'),
|
||||
('2*y', 'missing_x', 'Try including the variable x')]
|
||||
|
||||
('2*y', 'missing_x', 'Try including the variable x')]
|
||||
|
||||
# The expected solution is numerically equivalent to x+2y
|
||||
problem = self.build_problem(sample_dict=sample_dict,
|
||||
num_samples=10,
|
||||
tolerance=0.01,
|
||||
answer="x+2*y",
|
||||
hints=hints)
|
||||
num_samples=10,
|
||||
tolerance=0.01,
|
||||
answer="x+2*y",
|
||||
hints=hints)
|
||||
|
||||
# Expect to receive a hint if we add an extra y
|
||||
input_dict = {'1_2_1': "x + 2*y + y"}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_hint('1_2_1'),
|
||||
'Check the coefficient of y')
|
||||
'Check the coefficient of y')
|
||||
|
||||
# Expect to receive a hint if we leave out x
|
||||
input_dict = {'1_2_1': "2*y"}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_hint('1_2_1'),
|
||||
'Try including the variable x')
|
||||
|
||||
'Try including the variable x')
|
||||
|
||||
def test_script(self):
|
||||
# Calculate the answer using a script
|
||||
@@ -334,10 +348,10 @@ class FormulaResponseTest(ResponseTest):
|
||||
|
||||
# The expected solution is numerically equivalent to 2*x
|
||||
problem = self.build_problem(sample_dict=sample_dict,
|
||||
num_samples=10,
|
||||
tolerance=0.01,
|
||||
answer="$calculated_ans",
|
||||
script=script)
|
||||
num_samples=10,
|
||||
tolerance=0.01,
|
||||
answer="$calculated_ans",
|
||||
script=script)
|
||||
|
||||
# Expect that the inputs are graded correctly
|
||||
self.assert_grade(problem, '2*x', 'correct')
|
||||
@@ -348,7 +362,6 @@ class StringResponseTest(ResponseTest):
|
||||
from response_xml_factory import StringResponseXMLFactory
|
||||
xml_factory_class = StringResponseXMLFactory
|
||||
|
||||
|
||||
def test_case_sensitive(self):
|
||||
problem = self.build_problem(answer="Second", case_sensitive=True)
|
||||
|
||||
@@ -372,23 +385,23 @@ class StringResponseTest(ResponseTest):
|
||||
|
||||
def test_hints(self):
|
||||
hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"),
|
||||
("minnesota", "minn", "The state capital of Minnesota is St. Paul")]
|
||||
("minnesota", "minn", "The state capital of Minnesota is St. Paul")]
|
||||
|
||||
problem = self.build_problem(answer="Michigan",
|
||||
case_sensitive=False,
|
||||
hints=hints)
|
||||
case_sensitive=False,
|
||||
hints=hints)
|
||||
|
||||
# We should get a hint for Wisconsin
|
||||
input_dict = {'1_2_1': 'Wisconsin'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_hint('1_2_1'),
|
||||
"The state capital of Wisconsin is Madison")
|
||||
"The state capital of Wisconsin is Madison")
|
||||
|
||||
# We should get a hint for Minnesota
|
||||
input_dict = {'1_2_1': 'Minnesota'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_hint('1_2_1'),
|
||||
"The state capital of Minnesota is St. Paul")
|
||||
"The state capital of Minnesota is St. Paul")
|
||||
|
||||
# We should NOT get a hint for Michigan (the correct answer)
|
||||
input_dict = {'1_2_1': 'Michigan'}
|
||||
@@ -400,6 +413,7 @@ class StringResponseTest(ResponseTest):
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_hint('1_2_1'), "")
|
||||
|
||||
|
||||
class CodeResponseTest(ResponseTest):
|
||||
from response_xml_factory import CodeResponseXMLFactory
|
||||
xml_factory_class = CodeResponseXMLFactory
|
||||
@@ -409,9 +423,9 @@ class CodeResponseTest(ResponseTest):
|
||||
|
||||
grader_payload = json.dumps({"grader": "ps04/grade_square.py"})
|
||||
self.problem = self.build_problem(initial_display="def square(x):",
|
||||
answer_display="answer",
|
||||
grader_payload=grader_payload,
|
||||
num_responses=2)
|
||||
answer_display="answer",
|
||||
grader_payload=grader_payload,
|
||||
num_responses=2)
|
||||
|
||||
@staticmethod
|
||||
def make_queuestate(key, time):
|
||||
@@ -442,7 +456,6 @@ class CodeResponseTest(ResponseTest):
|
||||
|
||||
self.assertEquals(self.problem.is_queued(), True)
|
||||
|
||||
|
||||
def test_update_score(self):
|
||||
'''
|
||||
Test whether LoncapaProblem.update_score can deliver queued result to the right subproblem
|
||||
@@ -495,7 +508,6 @@ class CodeResponseTest(ResponseTest):
|
||||
else:
|
||||
self.assertTrue(self.problem.correct_map.is_queued(test_id)) # Should be queued, message undelivered
|
||||
|
||||
|
||||
def test_recentmost_queuetime(self):
|
||||
'''
|
||||
Test whether the LoncapaProblem knows about the time of queue requests
|
||||
@@ -538,13 +550,14 @@ class CodeResponseTest(ResponseTest):
|
||||
self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3'])
|
||||
self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name])
|
||||
|
||||
|
||||
class ChoiceResponseTest(ResponseTest):
|
||||
from response_xml_factory import ChoiceResponseXMLFactory
|
||||
xml_factory_class = ChoiceResponseXMLFactory
|
||||
|
||||
def test_radio_group_grade(self):
|
||||
problem = self.build_problem(choice_type='radio',
|
||||
choices=[False, True, False])
|
||||
choices=[False, True, False])
|
||||
|
||||
# Check that we get the expected results
|
||||
self.assert_grade(problem, 'choice_0', 'incorrect')
|
||||
@@ -554,10 +567,9 @@ class ChoiceResponseTest(ResponseTest):
|
||||
# No choice 3 exists --> mark incorrect
|
||||
self.assert_grade(problem, 'choice_3', 'incorrect')
|
||||
|
||||
|
||||
def test_checkbox_group_grade(self):
|
||||
problem = self.build_problem(choice_type='checkbox',
|
||||
choices=[False, True, True])
|
||||
choices=[False, True, True])
|
||||
|
||||
# Check that we get the expected results
|
||||
# (correct if and only if BOTH correct choices chosen)
|
||||
@@ -581,14 +593,15 @@ class JavascriptResponseTest(ResponseTest):
|
||||
os.system("coffee -c %s" % (coffee_file_path))
|
||||
|
||||
problem = self.build_problem(generator_src="test_problem_generator.js",
|
||||
grader_src="test_problem_grader.js",
|
||||
display_class="TestProblemDisplay",
|
||||
display_src="test_problem_display.js",
|
||||
param_dict={'value': '4'})
|
||||
grader_src="test_problem_grader.js",
|
||||
display_class="TestProblemDisplay",
|
||||
display_src="test_problem_display.js",
|
||||
param_dict={'value': '4'})
|
||||
|
||||
# Test that we get graded correctly
|
||||
self.assert_grade(problem, json.dumps({0:4}), "correct")
|
||||
self.assert_grade(problem, json.dumps({0:5}), "incorrect")
|
||||
self.assert_grade(problem, json.dumps({0: 4}), "correct")
|
||||
self.assert_grade(problem, json.dumps({0: 5}), "incorrect")
|
||||
|
||||
|
||||
class NumericalResponseTest(ResponseTest):
|
||||
from response_xml_factory import NumericalResponseXMLFactory
|
||||
@@ -596,27 +609,26 @@ class NumericalResponseTest(ResponseTest):
|
||||
|
||||
def test_grade_exact(self):
|
||||
problem = self.build_problem(question_text="What is 2 + 2?",
|
||||
explanation="The answer is 4",
|
||||
answer=4)
|
||||
explanation="The answer is 4",
|
||||
answer=4)
|
||||
correct_responses = ["4", "4.0", "4.00"]
|
||||
incorrect_responses = ["", "3.9", "4.1", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
|
||||
def test_grade_decimal_tolerance(self):
|
||||
problem = self.build_problem(question_text="What is 2 + 2 approximately?",
|
||||
explanation="The answer is 4",
|
||||
answer=4,
|
||||
tolerance=0.1)
|
||||
explanation="The answer is 4",
|
||||
answer=4,
|
||||
tolerance=0.1)
|
||||
correct_responses = ["4.0", "4.00", "4.09", "3.91"]
|
||||
incorrect_responses = ["", "4.11", "3.89", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
def test_grade_percent_tolerance(self):
|
||||
problem = self.build_problem(question_text="What is 2 + 2 approximately?",
|
||||
explanation="The answer is 4",
|
||||
answer=4,
|
||||
tolerance="10%")
|
||||
explanation="The answer is 4",
|
||||
answer=4,
|
||||
tolerance="10%")
|
||||
correct_responses = ["4.0", "4.3", "3.7", "4.30", "3.70"]
|
||||
incorrect_responses = ["", "4.5", "3.5", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
@@ -624,9 +636,9 @@ class NumericalResponseTest(ResponseTest):
|
||||
def test_grade_with_script(self):
|
||||
script_text = "computed_response = math.sqrt(4)"
|
||||
problem = self.build_problem(question_text="What is sqrt(4)?",
|
||||
explanation="The answer is 2",
|
||||
answer="$computed_response",
|
||||
script=script_text)
|
||||
explanation="The answer is 2",
|
||||
answer="$computed_response",
|
||||
script=script_text)
|
||||
correct_responses = ["2", "2.0"]
|
||||
incorrect_responses = ["", "2.01", "1.99", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
@@ -634,10 +646,10 @@ class NumericalResponseTest(ResponseTest):
|
||||
def test_grade_with_script_and_tolerance(self):
|
||||
script_text = "computed_response = math.sqrt(4)"
|
||||
problem = self.build_problem(question_text="What is sqrt(4)?",
|
||||
explanation="The answer is 2",
|
||||
answer="$computed_response",
|
||||
tolerance="0.1",
|
||||
script=script_text)
|
||||
explanation="The answer is 2",
|
||||
answer="$computed_response",
|
||||
tolerance="0.1",
|
||||
script=script_text)
|
||||
correct_responses = ["2", "2.0", "2.05", "1.95"]
|
||||
incorrect_responses = ["", "2.11", "1.89", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
@@ -651,7 +663,6 @@ class NumericalResponseTest(ResponseTest):
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
|
||||
|
||||
class CustomResponseTest(ResponseTest):
|
||||
from response_xml_factory import CustomResponseXMLFactory
|
||||
xml_factory_class = CustomResponseXMLFactory
|
||||
@@ -692,7 +703,6 @@ class CustomResponseTest(ResponseTest):
|
||||
overall_msg = correctmap.get_overall_message()
|
||||
self.assertEqual(overall_msg, "Overall message")
|
||||
|
||||
|
||||
def test_function_code_single_input(self):
|
||||
|
||||
# For function code, we pass in these arguments:
|
||||
@@ -746,7 +756,7 @@ class CustomResponseTest(ResponseTest):
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func",
|
||||
expect="42", num_inputs=2)
|
||||
expect="42", num_inputs=2)
|
||||
|
||||
# Correct answer -- expect both inputs marked correct
|
||||
input_dict = {'1_2_1': '42', '1_2_2': '42'}
|
||||
@@ -768,7 +778,6 @@ class CustomResponseTest(ResponseTest):
|
||||
correctness = correct_map.get_correctness('1_2_2')
|
||||
self.assertEqual(correctness, 'incorrect')
|
||||
|
||||
|
||||
def test_function_code_multiple_inputs(self):
|
||||
|
||||
# If the <customresponse> has multiple inputs associated with it,
|
||||
@@ -794,10 +803,10 @@ class CustomResponseTest(ResponseTest):
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script,
|
||||
cfn="check_func", num_inputs=3)
|
||||
cfn="check_func", num_inputs=3)
|
||||
|
||||
# Grade the inputs (one input incorrect)
|
||||
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3' }
|
||||
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
# Expect that we receive the overall message (for the whole response)
|
||||
@@ -813,7 +822,6 @@ class CustomResponseTest(ResponseTest):
|
||||
self.assertEqual(correct_map.get_msg('1_2_2'), 'Feedback 2')
|
||||
self.assertEqual(correct_map.get_msg('1_2_3'), 'Feedback 3')
|
||||
|
||||
|
||||
def test_multiple_inputs_return_one_status(self):
|
||||
# When given multiple inputs, the 'answer_given' argument
|
||||
# to the check_func() is a list of inputs
|
||||
@@ -835,10 +843,10 @@ class CustomResponseTest(ResponseTest):
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script,
|
||||
cfn="check_func", num_inputs=3)
|
||||
cfn="check_func", num_inputs=3)
|
||||
|
||||
# Grade the inputs (one input incorrect)
|
||||
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3' }
|
||||
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
# Everything marked incorrect
|
||||
@@ -847,7 +855,7 @@ class CustomResponseTest(ResponseTest):
|
||||
self.assertEqual(correct_map.get_correctness('1_2_3'), 'incorrect')
|
||||
|
||||
# Grade the inputs (everything correct)
|
||||
input_dict = {'1_2_1': '1', '1_2_2': '2', '1_2_3': '3' }
|
||||
input_dict = {'1_2_1': '1', '1_2_2': '2', '1_2_3': '3'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
# Everything marked incorrect
|
||||
@@ -858,7 +866,7 @@ class CustomResponseTest(ResponseTest):
|
||||
# Message is interpreted as an "overall message"
|
||||
self.assertEqual(correct_map.get_overall_message(), 'Message text')
|
||||
|
||||
def test_script_exception(self):
|
||||
def test_script_exception_function(self):
|
||||
|
||||
# Construct a script that will raise an exception
|
||||
script = textwrap.dedent("""
|
||||
@@ -869,7 +877,17 @@ class CustomResponseTest(ResponseTest):
|
||||
problem = self.build_problem(script=script, cfn="check_func")
|
||||
|
||||
# Expect that an exception gets raised when we check the answer
|
||||
with self.assertRaises(Exception):
|
||||
with self.assertRaises(ResponseError):
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
def test_script_exception_inline(self):
|
||||
|
||||
# Construct a script that will raise an exception
|
||||
script = 'raise Exception("Test")'
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
# Expect that an exception gets raised when we check the answer
|
||||
with self.assertRaises(ResponseError):
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
def test_invalid_dict_exception(self):
|
||||
@@ -883,10 +901,70 @@ class CustomResponseTest(ResponseTest):
|
||||
problem = self.build_problem(script=script, cfn="check_func")
|
||||
|
||||
# Expect that an exception gets raised when we check the answer
|
||||
with self.assertRaises(Exception):
|
||||
with self.assertRaises(ResponseError):
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
|
||||
def test_module_imports_inline(self):
|
||||
'''
|
||||
Check that the correct modules are available to custom
|
||||
response scripts
|
||||
'''
|
||||
|
||||
for module_name in ['random', 'numpy', 'math', 'scipy',
|
||||
'calc', 'eia', 'chemcalc', 'chemtools',
|
||||
'miller', 'draganddrop']:
|
||||
|
||||
# Create a script that checks that the name is defined
|
||||
# If the name is not defined, then the script
|
||||
# will raise an exception
|
||||
script = textwrap.dedent('''
|
||||
correct[0] = 'correct'
|
||||
assert('%s' in globals())''' % module_name)
|
||||
|
||||
# Create the problem
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
# Expect that we can grade an answer without
|
||||
# getting an exception
|
||||
try:
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
except ResponseError:
|
||||
self.fail("Could not use name '%s' in custom response"
|
||||
% module_name)
|
||||
|
||||
def test_module_imports_function(self):
|
||||
'''
|
||||
Check that the correct modules are available to custom
|
||||
response scripts
|
||||
'''
|
||||
|
||||
for module_name in ['random', 'numpy', 'math', 'scipy',
|
||||
'calc', 'eia', 'chemcalc', 'chemtools',
|
||||
'miller', 'draganddrop']:
|
||||
|
||||
# Create a script that checks that the name is defined
|
||||
# If the name is not defined, then the script
|
||||
# will raise an exception
|
||||
script = textwrap.dedent('''
|
||||
def check_func(expect, answer_given):
|
||||
assert('%s' in globals())
|
||||
return True''' % module_name)
|
||||
|
||||
# Create the problem
|
||||
problem = self.build_problem(script=script, cfn="check_func")
|
||||
|
||||
# Expect that we can grade an answer without
|
||||
# getting an exception
|
||||
try:
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
except ResponseError:
|
||||
self.fail("Could not use name '%s' in custom response"
|
||||
% module_name)
|
||||
|
||||
|
||||
class SchematicResponseTest(ResponseTest):
|
||||
from response_xml_factory import SchematicResponseXMLFactory
|
||||
xml_factory_class = SchematicResponseXMLFactory
|
||||
@@ -902,13 +980,13 @@ class SchematicResponseTest(ResponseTest):
|
||||
# To test that the context is set up correctly,
|
||||
# we create a script that sets *correct* to true
|
||||
# if and only if we find the *submission* (list)
|
||||
script="correct = ['correct' if 'test' in submission[0] else 'incorrect']"
|
||||
script = "correct = ['correct' if 'test' in submission[0] else 'incorrect']"
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
# The actual dictionary would contain schematic information
|
||||
# sent from the JavaScript simulation
|
||||
submission_dict = {'test': 'test'}
|
||||
input_dict = { '1_2_1': json.dumps(submission_dict) }
|
||||
input_dict = {'1_2_1': json.dumps(submission_dict)}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
# Expect that the problem is graded as true
|
||||
@@ -916,6 +994,19 @@ class SchematicResponseTest(ResponseTest):
|
||||
# is what we expect)
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
|
||||
|
||||
def test_script_exception(self):
|
||||
|
||||
# Construct a script that will raise an exception
|
||||
script = "raise Exception('test')"
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
# Expect that an exception gets raised when we check the answer
|
||||
with self.assertRaises(ResponseError):
|
||||
submission_dict = {'test': 'test'}
|
||||
input_dict = {'1_2_1': json.dumps(submission_dict)}
|
||||
problem.grade_answers(input_dict)
|
||||
|
||||
|
||||
class AnnotationResponseTest(ResponseTest):
|
||||
from response_xml_factory import AnnotationResponseXMLFactory
|
||||
xml_factory_class = AnnotationResponseXMLFactory
|
||||
@@ -924,18 +1015,18 @@ class AnnotationResponseTest(ResponseTest):
|
||||
(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 })}
|
||||
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' } },
|
||||
{'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):
|
||||
|
||||
@@ -20,8 +20,7 @@ class AnnotatableModule(AnnotatableFields, XModule):
|
||||
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': []}
|
||||
js_module_name = "Annotatable"
|
||||
css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]}
|
||||
icon_class = 'annotatable'
|
||||
@@ -49,11 +48,11 @@ class AnnotatableModule(AnnotatableFields, XModule):
|
||||
|
||||
if color is not None:
|
||||
if color in self.highlight_colors:
|
||||
cls.append('highlight-'+color)
|
||||
cls.append('highlight-' + color)
|
||||
attr['_delete'] = highlight_key
|
||||
attr['value'] = ' '.join(cls)
|
||||
|
||||
return { 'class' : attr }
|
||||
return {'class': attr}
|
||||
|
||||
def _get_annotation_data_attr(self, index, el):
|
||||
""" Returns a dict in which the keys are the HTML data attributes
|
||||
@@ -73,7 +72,7 @@ class AnnotatableModule(AnnotatableFields, XModule):
|
||||
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 }
|
||||
data_attrs[html_key] = {'value': value, '_delete': xml_key}
|
||||
|
||||
return data_attrs
|
||||
|
||||
@@ -91,7 +90,6 @@ class AnnotatableModule(AnnotatableFields, XModule):
|
||||
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)
|
||||
@@ -132,4 +130,3 @@ class AnnotatableDescriptor(AnnotatableFields, RawDescriptor):
|
||||
stores_state = True
|
||||
template_dir_name = "annotatable"
|
||||
mako_template = "widgets/raw-edit.html"
|
||||
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import cgi
|
||||
import datetime
|
||||
import dateutil
|
||||
import dateutil.parser
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from capa.capa_problem import LoncapaProblem
|
||||
from capa.responsetypes import StudentInputError
|
||||
from capa.responsetypes import StudentInputError,\
|
||||
ResponseError, LoncapaProblemError
|
||||
from capa.util import convert_files_to_filenames
|
||||
from .progress import Progress
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float
|
||||
from .fields import Timedelta
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xblock.core import Integer, Scope, String, Boolean, Object, Float
|
||||
from .fields import Timedelta, Date
|
||||
from xmodule.util.date_utils import time_to_datetime
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -86,13 +85,14 @@ class ComplexEncoder(json.JSONEncoder):
|
||||
class CapaFields(object):
|
||||
attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state)
|
||||
max_attempts = StringyInteger(help="Maximum number of attempts that a student is allowed", scope=Scope.settings)
|
||||
due = String(help="Date that this problem is due by", scope=Scope.settings)
|
||||
due = Date(help="Date that this problem is due by", scope=Scope.settings)
|
||||
graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings)
|
||||
showanswer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed")
|
||||
force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False)
|
||||
rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings)
|
||||
data = String(help="XML data for the problem", scope=Scope.content)
|
||||
correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state, default={})
|
||||
input_state = Object(help="Dictionary for maintaining the state of inputtypes", scope=Scope.student_state)
|
||||
student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state)
|
||||
done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state)
|
||||
display_name = String(help="Display name for this module", scope=Scope.settings)
|
||||
@@ -123,10 +123,7 @@ class CapaModule(CapaFields, XModule):
|
||||
def __init__(self, system, location, descriptor, model_data):
|
||||
XModule.__init__(self, system, location, descriptor, model_data)
|
||||
|
||||
if self.due:
|
||||
due_date = dateutil.parser.parse(self.due)
|
||||
else:
|
||||
due_date = None
|
||||
due_date = time_to_datetime(self.due)
|
||||
|
||||
if self.graceperiod is not None and due_date:
|
||||
self.close_date = due_date + self.graceperiod
|
||||
@@ -149,6 +146,16 @@ class CapaModule(CapaFields, XModule):
|
||||
# TODO (vshnayder): move as much as possible of this work and error
|
||||
# checking to descriptor load time
|
||||
self.lcp = self.new_lcp(self.get_state_for_lcp())
|
||||
|
||||
# At this point, we need to persist the randomization seed
|
||||
# so that when the problem is re-loaded (to check/view/save)
|
||||
# it stays the same.
|
||||
# However, we do not want to write to the database
|
||||
# every time the module is loaded.
|
||||
# So we set the seed ONLY when there is not one set already
|
||||
if self.seed is None:
|
||||
self.seed = self.lcp.seed
|
||||
|
||||
except Exception as err:
|
||||
msg = 'cannot create LoncapaProblem {loc}: {err}'.format(
|
||||
loc=self.location.url(), err=err)
|
||||
@@ -188,6 +195,7 @@ class CapaModule(CapaFields, XModule):
|
||||
'done': self.done,
|
||||
'correct_map': self.correct_map,
|
||||
'student_answers': self.student_answers,
|
||||
'input_state': self.input_state,
|
||||
'seed': self.seed,
|
||||
}
|
||||
|
||||
@@ -195,6 +203,7 @@ class CapaModule(CapaFields, XModule):
|
||||
lcp_state = self.lcp.get_state()
|
||||
self.done = lcp_state['done']
|
||||
self.correct_map = lcp_state['correct_map']
|
||||
self.input_state = lcp_state['input_state']
|
||||
self.student_answers = lcp_state['student_answers']
|
||||
self.seed = lcp_state['seed']
|
||||
|
||||
@@ -443,14 +452,22 @@ class CapaModule(CapaFields, XModule):
|
||||
'problem_save': self.save_problem,
|
||||
'problem_show': self.get_answer,
|
||||
'score_update': self.update_score,
|
||||
'input_ajax': self.lcp.handle_input_ajax
|
||||
'input_ajax': self.handle_input_ajax,
|
||||
'ungraded_response': self.handle_ungraded_response
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
return 'Error'
|
||||
|
||||
before = self.get_progress()
|
||||
d = handlers[dispatch](get)
|
||||
|
||||
try:
|
||||
d = handlers[dispatch](get)
|
||||
|
||||
except Exception as err:
|
||||
_, _, traceback_obj = sys.exc_info()
|
||||
raise ProcessingError, err.message, traceback_obj
|
||||
|
||||
after = self.get_progress()
|
||||
d.update({
|
||||
'progress_changed': after != before,
|
||||
@@ -537,6 +554,43 @@ class CapaModule(CapaFields, XModule):
|
||||
|
||||
return dict() # No AJAX return is needed
|
||||
|
||||
def handle_ungraded_response(self, get):
|
||||
'''
|
||||
Delivers a response from the XQueue to the capa problem
|
||||
|
||||
The score of the problem will not be updated
|
||||
|
||||
Args:
|
||||
- get (dict) must contain keys:
|
||||
queuekey - a key specific to this response
|
||||
xqueue_body - the body of the response
|
||||
Returns:
|
||||
empty dictionary
|
||||
|
||||
No ajax return is needed, so an empty dict is returned
|
||||
'''
|
||||
queuekey = get['queuekey']
|
||||
score_msg = get['xqueue_body']
|
||||
# pass along the xqueue message to the problem
|
||||
self.lcp.ungraded_response(score_msg, queuekey)
|
||||
self.set_state_from_lcp()
|
||||
return dict()
|
||||
|
||||
def handle_input_ajax(self, get):
|
||||
'''
|
||||
Handle ajax calls meant for a particular input in the problem
|
||||
|
||||
Args:
|
||||
- get (dict) - data that should be passed to the input
|
||||
Returns:
|
||||
- dict containing the response from the input
|
||||
'''
|
||||
response = self.lcp.handle_input_ajax(get)
|
||||
# save any state changes that may occur
|
||||
self.set_state_from_lcp()
|
||||
return response
|
||||
|
||||
|
||||
def get_answer(self, get):
|
||||
'''
|
||||
For the "show answer" button.
|
||||
@@ -684,9 +738,24 @@ class CapaModule(CapaFields, XModule):
|
||||
try:
|
||||
correct_map = self.lcp.grade_answers(answers)
|
||||
self.set_state_from_lcp()
|
||||
except StudentInputError as inst:
|
||||
log.exception("StudentInputError in capa_module:problem_check")
|
||||
return {'success': inst.message}
|
||||
|
||||
except (StudentInputError, ResponseError, LoncapaProblemError) as inst:
|
||||
log.warning("StudentInputError in capa_module:problem_check",
|
||||
exc_info=True)
|
||||
|
||||
# If the user is a staff member, include
|
||||
# the full exception, including traceback,
|
||||
# in the response
|
||||
if self.system.user_is_staff:
|
||||
msg = "Staff debug info: %s" % traceback.format_exc()
|
||||
|
||||
# Otherwise, display just an error message,
|
||||
# without a stack trace
|
||||
else:
|
||||
msg = "Error: %s" % str(inst.message)
|
||||
|
||||
return {'success': msg}
|
||||
|
||||
except Exception, err:
|
||||
if self.system.DEBUG:
|
||||
msg = "Error checking problem: " + str(err)
|
||||
@@ -737,7 +806,7 @@ class CapaModule(CapaFields, XModule):
|
||||
event_info['answers'] = answers
|
||||
|
||||
# Too late. Cannot submit
|
||||
if self.closed() and not self.max_attempts ==0:
|
||||
if self.closed() and not self.max_attempts == 0:
|
||||
event_info['failure'] = 'closed'
|
||||
self.system.track_function('save_problem_fail', event_info)
|
||||
return {'success': False,
|
||||
@@ -757,7 +826,7 @@ class CapaModule(CapaFields, XModule):
|
||||
|
||||
self.system.track_function('save_problem_success', event_info)
|
||||
msg = "Your answers have been saved"
|
||||
if not self.max_attempts ==0:
|
||||
if not self.max_attempts == 0:
|
||||
msg += " but not graded. Hit 'Check' to grade them."
|
||||
return {'success': True,
|
||||
'msg': msg}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
from lxml import etree
|
||||
|
||||
@@ -6,14 +5,16 @@ from pkg_resources import resource_string
|
||||
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from .x_module import XModule
|
||||
from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float, List
|
||||
from xblock.core import Integer, Scope, String, Boolean, List
|
||||
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
|
||||
from collections import namedtuple
|
||||
from .fields import Date
|
||||
from xmodule.open_ended_grading_classes.xblock_field_types import StringyFloat
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload",
|
||||
"skip_spelling_checks", "due", "graceperiod", "max_score"]
|
||||
"skip_spelling_checks", "due", "graceperiod"]
|
||||
|
||||
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
|
||||
"student_attempts", "ready_to_reset"]
|
||||
@@ -63,12 +64,12 @@ class CombinedOpenEndedFields(object):
|
||||
scope=Scope.settings)
|
||||
skip_spelling_checks = Boolean(help="Whether or not to skip initial spelling checks.", default=True,
|
||||
scope=Scope.settings)
|
||||
due = String(help="Date that this problem is due by", default=None, scope=Scope.settings)
|
||||
due = Date(help="Date that this problem is due by", default=None, scope=Scope.settings)
|
||||
graceperiod = String(help="Amount of time after the due date that submissions will be accepted", default=None,
|
||||
scope=Scope.settings)
|
||||
max_score = Integer(help="Maximum score for the problem.", default=1, scope=Scope.settings)
|
||||
version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
|
||||
data = String(help="XML data for the problem", scope=Scope.content)
|
||||
weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings)
|
||||
|
||||
|
||||
class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
@@ -104,10 +105,11 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
|
||||
icon_class = 'problem'
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
js = {'coffee':
|
||||
[resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
js_module_name = "CombinedOpenEnded"
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
||||
@@ -118,7 +120,7 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
Definition file should have one or many task blocks, a rubric block, and a prompt block:
|
||||
|
||||
Sample file:
|
||||
<combinedopenended attempts="10000" max_score="1">
|
||||
<combinedopenended attempts="10000">
|
||||
<rubric>
|
||||
Blah blah rubric.
|
||||
</rubric>
|
||||
@@ -190,8 +192,8 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
def get_score(self):
|
||||
return self.child_module.get_score()
|
||||
|
||||
#def max_score(self):
|
||||
# return self.child_module.max_score()
|
||||
def max_score(self):
|
||||
return self.child_module.max_score()
|
||||
|
||||
def get_progress(self):
|
||||
return self.child_module.get_progress()
|
||||
@@ -218,4 +220,3 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "combinedopenended"
|
||||
|
||||
|
||||
@@ -40,8 +40,21 @@ class ConditionalModule(ConditionalFields, XModule):
|
||||
poll_answer - map to `poll_answer` module attribute
|
||||
voted - map to `voted` module attribute
|
||||
|
||||
<conditional> tag attributes:
|
||||
sources - location id of modules, separated by ';'
|
||||
<show> tag attributes:
|
||||
sources - location id of required modules, separated by ';'
|
||||
|
||||
You can add you own rules for <conditional> tag, like
|
||||
"completed", "attempted" etc. To do that yo must extend
|
||||
`ConditionalModule.conditions_map` variable and add pair:
|
||||
my_attr: my_property/my_method
|
||||
|
||||
After that you can use it:
|
||||
<conditional my_attr="some value" ...>
|
||||
...
|
||||
</conditional>
|
||||
|
||||
And my_property/my_method will be called for required modules.
|
||||
|
||||
"""
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import logging
|
||||
from cStringIO import StringIO
|
||||
from math import exp, erf
|
||||
from math import exp
|
||||
from lxml import etree
|
||||
from path import path # NOTE (THK): Only used for detecting presence of syllabus
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.timeparse import parse_time
|
||||
from xmodule.util.decorators import lazyproperty
|
||||
from xmodule.graders import grader_from_conf
|
||||
from datetime import datetime
|
||||
from xmodule.util.date_utils import time_to_datetime
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import time
|
||||
import copy
|
||||
|
||||
from xblock.core import Scope, ModelType, List, String, Object, Boolean
|
||||
from xblock.core import Scope, List, String, Object, Boolean
|
||||
from .fields import Date
|
||||
|
||||
|
||||
@@ -29,30 +27,30 @@ log = logging.getLogger(__name__)
|
||||
class StringOrDate(Date):
|
||||
def from_json(self, value):
|
||||
"""
|
||||
Parse an optional metadata key containing a time: if present, complain
|
||||
if it doesn't parse.
|
||||
Return None if not present or invalid.
|
||||
Parse an optional metadata key containing a time or a string:
|
||||
if present, assume it's a string if it doesn't parse.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return time.strptime(value, self.time_format)
|
||||
result = super(StringOrDate, self).from_json(value)
|
||||
except ValueError:
|
||||
return value
|
||||
if result is None:
|
||||
return value
|
||||
else:
|
||||
return result
|
||||
|
||||
def to_json(self, value):
|
||||
"""
|
||||
Convert a time struct to a string
|
||||
Convert a time struct or string to a string.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return time.strftime(self.time_format, value)
|
||||
except (ValueError, TypeError):
|
||||
result = super(StringOrDate, self).to_json(value)
|
||||
except:
|
||||
return value
|
||||
|
||||
if result is None:
|
||||
return value
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
|
||||
@@ -60,6 +58,7 @@ edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
|
||||
|
||||
_cached_toc = {}
|
||||
|
||||
|
||||
class Textbook(object):
|
||||
def __init__(self, title, book_url):
|
||||
self.title = title
|
||||
@@ -154,7 +153,7 @@ class CourseFields(object):
|
||||
enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings)
|
||||
start = Date(help="Start time when this module is visible", scope=Scope.settings)
|
||||
end = Date(help="Date that this class ends", scope=Scope.settings)
|
||||
advertised_start = StringOrDate(help="Date that this course is advertised to start", scope=Scope.settings)
|
||||
advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings)
|
||||
grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content)
|
||||
show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
|
||||
display_name = String(help="Display name for this module", scope=Scope.settings)
|
||||
@@ -179,7 +178,7 @@ class CourseFields(object):
|
||||
allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False)
|
||||
advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings)
|
||||
has_children = True
|
||||
|
||||
checklists = List(scope=Scope.settings)
|
||||
info_sidebar_name = String(scope=Scope.settings, default='Course Handouts')
|
||||
|
||||
# An extra property is used rather than the wiki_slug/number because
|
||||
@@ -367,7 +366,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
textbooks.append((textbook.get('title'), textbook.get('book_url')))
|
||||
xml_object.remove(textbook)
|
||||
|
||||
#Load the wiki tag if it exists
|
||||
# Load the wiki tag if it exists
|
||||
wiki_slug = None
|
||||
wiki_tag = xml_object.find("wiki")
|
||||
if wiki_tag is not None:
|
||||
@@ -535,17 +534,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
def _sorting_dates(self):
|
||||
# utility function to get datetime objects for dates used to
|
||||
# compute the is_new flag and the sorting_score
|
||||
def to_datetime(timestamp):
|
||||
return datetime(*timestamp[:6])
|
||||
|
||||
announcement = self.announcement
|
||||
if announcement is not None:
|
||||
announcement = to_datetime(announcement)
|
||||
if self.advertised_start is None or isinstance(self.advertised_start, basestring):
|
||||
start = to_datetime(self.start)
|
||||
else:
|
||||
start = to_datetime(self.advertised_start)
|
||||
now = to_datetime(time.gmtime())
|
||||
announcement = time_to_datetime(announcement)
|
||||
|
||||
try:
|
||||
start = dateutil.parser.parse(self.advertised_start)
|
||||
except (ValueError, AttributeError):
|
||||
start = time_to_datetime(self.start)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
return announcement, start, now
|
||||
|
||||
@@ -635,8 +634,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
@property
|
||||
def start_date_text(self):
|
||||
def try_parse_iso_8601(text):
|
||||
try:
|
||||
result = datetime.strptime(text, "%Y-%m-%dT%H:%M")
|
||||
result = result.strftime("%b %d, %Y")
|
||||
except ValueError:
|
||||
result = text.title()
|
||||
|
||||
return result
|
||||
|
||||
if isinstance(self.advertised_start, basestring):
|
||||
return self.advertised_start
|
||||
return try_parse_iso_8601(self.advertised_start)
|
||||
elif self.advertised_start is None and self.start is None:
|
||||
return 'TBD'
|
||||
else:
|
||||
@@ -675,7 +683,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
# *end* of the same day, not the same time. It's going to be used as the
|
||||
# end of the exam overall, so we don't want the exam to disappear too soon.
|
||||
# It's also used optionally as the registration end date, so time matters there too.
|
||||
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
|
||||
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
|
||||
if self.last_eligible_appointment_date is None:
|
||||
raise ValueError("Last appointment date must be specified")
|
||||
self.registration_start_date = self._try_parse_time('Registration_Start_Date') or time.gmtime(0)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/* TODO: move top-level variables to a common _variables.scss.
|
||||
* NOTE: These variables were only added here because when this was integrated with the CMS,
|
||||
* SASS compilation errors were triggered because the CMS didn't have the same variables defined
|
||||
* that the LMS did, so the quick fix was to localize the LMS variables not shared by the CMS.
|
||||
* -Abarrett and Vshnayder
|
||||
*/
|
||||
$border-color: #C8C8C8;
|
||||
$body-font-size: em(14);
|
||||
|
||||
.annotatable-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.annotatable-header {
|
||||
margin-bottom: .5em;
|
||||
.annotatable-title {
|
||||
@@ -55,6 +65,7 @@ $body-font-size: em(14);
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
|
||||
$highlight_index: 0;
|
||||
@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)),
|
||||
@@ -62,12 +73,13 @@ $body-font-size: em(14);
|
||||
(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))) {
|
||||
|
||||
|
||||
$highlight_index: $highlight_index + 1;
|
||||
$marker: nth($highlight,1);
|
||||
$color: nth($highlight,2);
|
||||
$selected_color: nth($highlight,3);
|
||||
|
||||
@if $marker == yellow {
|
||||
@if $highlight_index == 1 {
|
||||
&.highlight {
|
||||
background-color: $color;
|
||||
&.selected { background-color: $selected_color; }
|
||||
@@ -127,6 +139,7 @@ $body-font-size: em(14);
|
||||
font-weight: 400;
|
||||
padding: 0 10px 10px 10px;
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
p {
|
||||
color: inherit;
|
||||
@@ -143,6 +156,7 @@ $body-font-size: em(14);
|
||||
margin: 0px 0px 10px 0;
|
||||
max-height: 225px;
|
||||
overflow: auto;
|
||||
line-height: normal;
|
||||
}
|
||||
.annotatable-reply {
|
||||
display: block;
|
||||
@@ -165,5 +179,3 @@ $body-font-size: em(14);
|
||||
border-top-color: rgba(0, 0, 0, .85);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
class InvalidDefinitionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
pass
|
||||
|
||||
class ProcessingError(Exception):
|
||||
'''
|
||||
An error occurred while processing a request to the XModule.
|
||||
For example: if an exception occurs while checking a capa problem.
|
||||
'''
|
||||
pass
|
||||
|
||||
@@ -4,27 +4,36 @@ import re
|
||||
|
||||
from datetime import timedelta
|
||||
from xblock.core import ModelType
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Date(ModelType):
|
||||
time_format = "%Y-%m-%dT%H:%M"
|
||||
|
||||
def from_json(self, value):
|
||||
'''
|
||||
Date fields know how to parse and produce json (iso) compatible formats.
|
||||
'''
|
||||
def from_json(self, field):
|
||||
"""
|
||||
Parse an optional metadata key containing a time: if present, complain
|
||||
if it doesn't parse.
|
||||
Return None if not present or invalid.
|
||||
"""
|
||||
if value is None:
|
||||
if field is None:
|
||||
return field
|
||||
elif field is "":
|
||||
return None
|
||||
|
||||
try:
|
||||
return time.strptime(value, self.time_format)
|
||||
except ValueError as e:
|
||||
msg = "Field {0} has bad value '{1}': '{2}'".format(
|
||||
self._name, value, e)
|
||||
elif isinstance(field, basestring):
|
||||
d = dateutil.parser.parse(field)
|
||||
return d.utctimetuple()
|
||||
elif isinstance(field, (int, long, float)):
|
||||
return time.gmtime(field / 1000)
|
||||
elif isinstance(field, time.struct_time):
|
||||
return field
|
||||
else:
|
||||
msg = "Field {0} has bad value '{1}'".format(
|
||||
self._name, field)
|
||||
log.warning(msg)
|
||||
return None
|
||||
|
||||
@@ -34,8 +43,11 @@ class Date(ModelType):
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return time.strftime(self.time_format, value)
|
||||
if isinstance(value, time.struct_time):
|
||||
# struct_times are always utc
|
||||
return time.strftime('%Y-%m-%dT%H:%M:%SZ', value)
|
||||
elif isinstance(value, datetime.datetime):
|
||||
return value.isoformat() + 'Z'
|
||||
|
||||
|
||||
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
|
||||
@@ -66,4 +78,4 @@ class Timedelta(ModelType):
|
||||
cur_value = getattr(value, attr, 0)
|
||||
if cur_value > 0:
|
||||
values.append("%d %s" % (cur_value, attr))
|
||||
return ' '.join(values)
|
||||
return ' '.join(values)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
from lxml import etree
|
||||
from dateutil import parser
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
@@ -8,6 +7,9 @@ from xmodule.editing_module import EditingDescriptor
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xblock.core import Scope, Integer, String
|
||||
from .fields import Date
|
||||
from xmodule.util.date_utils import time_to_datetime
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,7 +18,7 @@ class FolditFields(object):
|
||||
# default to what Spring_7012x uses
|
||||
required_level = Integer(default=4, scope=Scope.settings)
|
||||
required_sublevel = Integer(default=5, scope=Scope.settings)
|
||||
due = String(help="Date that this problem is due by", scope=Scope.settings, default='')
|
||||
due = Date(help="Date that this problem is due by", scope=Scope.settings)
|
||||
|
||||
show_basic_score = String(scope=Scope.settings, default='false')
|
||||
show_leaderboard = String(scope=Scope.settings, default='false')
|
||||
@@ -36,17 +38,8 @@ class FolditModule(FolditFields, XModule):
|
||||
required_sublevel="3"
|
||||
show_leaderboard="false"/>
|
||||
"""
|
||||
def parse_due_date():
|
||||
"""
|
||||
Pull out the date, or None
|
||||
"""
|
||||
s = self.due
|
||||
if s:
|
||||
return parser.parse(s)
|
||||
else:
|
||||
return None
|
||||
|
||||
self.due_time = parse_due_date()
|
||||
self.due_time = time_to_datetime(self.due)
|
||||
|
||||
def is_complete(self):
|
||||
"""
|
||||
@@ -178,8 +171,8 @@ class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
return ({}, [])
|
||||
return {}, []
|
||||
|
||||
def definition_to_xml(self):
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = etree.Element('foldit')
|
||||
return xml_object
|
||||
|
||||
@@ -45,8 +45,9 @@ def invalid_args(func, argdict):
|
||||
Given a function and a dictionary of arguments, returns a set of arguments
|
||||
from argdict that aren't accepted by func
|
||||
"""
|
||||
args, varargs, keywords, defaults = inspect.getargspec(func)
|
||||
if keywords: return set() # All accepted
|
||||
args, _, keywords, _ = inspect.getargspec(func)
|
||||
if keywords:
|
||||
return set() # All accepted
|
||||
return set(argdict) - set(args)
|
||||
|
||||
|
||||
@@ -119,7 +120,7 @@ class CourseGrader(object):
|
||||
that has the matching section format.
|
||||
|
||||
The grader outputs a dictionary with the following keys:
|
||||
- percent: Contaisn a float value, which is the final percentage score for the student.
|
||||
- percent: Contains a float value, which is the final percentage score for the student.
|
||||
- section_breakdown: This is a list of dictionaries which provide details on sections
|
||||
that were graded. These are used for display in a graph or chart. The format for a
|
||||
section_breakdown dictionary is explained below.
|
||||
@@ -150,6 +151,7 @@ class CourseGrader(object):
|
||||
|
||||
@abc.abstractmethod
|
||||
def grade(self, grade_sheet, generate_random_scores=False):
|
||||
'''Given a grade sheet, return a dict containing grading information'''
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -158,7 +160,10 @@ class WeightedSubsectionsGrader(CourseGrader):
|
||||
This grader takes a list of tuples containing (grader, category_name, weight) and computes
|
||||
a final grade by totalling the contribution of each sub grader and multiplying it by the
|
||||
given weight. For example, the sections may be
|
||||
[ (homeworkGrader, "Homework", 0.15), (labGrader, "Labs", 0.15), (midtermGrader, "Midterm", 0.30), (finalGrader, "Final", 0.40) ]
|
||||
|
||||
[ (homeworkGrader, "Homework", 0.15), (labGrader, "Labs", 0.15), (midtermGrader, "Midterm", 0.30),
|
||||
(finalGrader, "Final", 0.40) ]
|
||||
|
||||
All items in section_breakdown for each subgrader will be combined. A grade_breakdown will be
|
||||
composed using the score from each grader.
|
||||
|
||||
@@ -177,12 +182,12 @@ class WeightedSubsectionsGrader(CourseGrader):
|
||||
for subgrader, category, weight in self.sections:
|
||||
subgrade_result = subgrader.grade(grade_sheet, generate_random_scores)
|
||||
|
||||
weightedPercent = subgrade_result['percent'] * weight
|
||||
section_detail = "{0} = {1:.1%} of a possible {2:.0%}".format(category, weightedPercent, weight)
|
||||
weighted_percent = subgrade_result['percent'] * weight
|
||||
section_detail = "{0} = {1:.1%} of a possible {2:.0%}".format(category, weighted_percent, weight)
|
||||
|
||||
total_percent += weightedPercent
|
||||
total_percent += weighted_percent
|
||||
section_breakdown += subgrade_result['section_breakdown']
|
||||
grade_breakdown.append({'percent': weightedPercent, 'detail': section_detail, 'category': category})
|
||||
grade_breakdown.append({'percent': weighted_percent, 'detail': section_detail, 'category': category})
|
||||
|
||||
return {'percent': total_percent,
|
||||
'section_breakdown': section_breakdown,
|
||||
@@ -203,32 +208,33 @@ class SingleSectionGrader(CourseGrader):
|
||||
self.category = category or name
|
||||
|
||||
def grade(self, grade_sheet, generate_random_scores=False):
|
||||
foundScore = None
|
||||
found_score = None
|
||||
if self.type in grade_sheet:
|
||||
for score in grade_sheet[self.type]:
|
||||
if score.section == self.name:
|
||||
foundScore = score
|
||||
found_score = score
|
||||
break
|
||||
|
||||
if foundScore or generate_random_scores:
|
||||
if found_score or generate_random_scores:
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2, 15)
|
||||
possible = random.randint(earned, 15)
|
||||
else: # We found the score
|
||||
earned = foundScore.earned
|
||||
possible = foundScore.possible
|
||||
earned = found_score.earned
|
||||
possible = found_score.possible
|
||||
|
||||
percent = earned / float(possible)
|
||||
detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(name=self.name,
|
||||
percent=percent,
|
||||
earned=float(earned),
|
||||
possible=float(possible))
|
||||
percent=percent,
|
||||
earned=float(earned),
|
||||
possible=float(possible))
|
||||
|
||||
else:
|
||||
percent = 0.0
|
||||
detail = "{name} - 0% (?/?)".format(name=self.name)
|
||||
|
||||
breakdown = [{'percent': percent, 'label': self.short_label, 'detail': detail, 'category': self.category, 'prominent': True}]
|
||||
breakdown = [{'percent': percent, 'label': self.short_label,
|
||||
'detail': detail, 'category': self.category, 'prominent': True}]
|
||||
|
||||
return {'percent': percent,
|
||||
'section_breakdown': breakdown,
|
||||
@@ -250,6 +256,13 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
show_only_average is to suppress the display of each assignment in this grader and instead
|
||||
only show the total score of this grader in the breakdown.
|
||||
|
||||
hide_average is to suppress the display of the total score in this grader and instead
|
||||
only show each assignment in this grader in the breakdown.
|
||||
|
||||
If there is only a single assignment in this grader, then it acts like a SingleSectionGrader
|
||||
and returns only one entry for the grader. Since the assignment and the total are the same,
|
||||
the total is returned but is not labeled as an average.
|
||||
|
||||
category should be presentable to the user, but may not appear. When the grade breakdown is
|
||||
displayed, scores from the same category will be similar (for example, by color).
|
||||
|
||||
@@ -263,7 +276,8 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
min_count = 2 would produce the labels "Assignment 3", "Assignment 4"
|
||||
|
||||
"""
|
||||
def __init__(self, type, min_count, drop_count, category=None, section_type=None, short_label=None, show_only_average=False, hide_average=False, starting_index=1):
|
||||
def __init__(self, type, min_count, drop_count, category=None, section_type=None, short_label=None,
|
||||
show_only_average=False, hide_average=False, starting_index=1):
|
||||
self.type = type
|
||||
self.min_count = min_count
|
||||
self.drop_count = drop_count
|
||||
@@ -275,7 +289,8 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
self.hide_average = hide_average
|
||||
|
||||
def grade(self, grade_sheet, generate_random_scores=False):
|
||||
def totalWithDrops(breakdown, drop_count):
|
||||
def total_with_drops(breakdown, drop_count):
|
||||
'''calculates total score for a section while dropping lowest scores'''
|
||||
#create an array of tuples with (index, mark), sorted by mark['percent'] descending
|
||||
sorted_breakdown = sorted(enumerate(breakdown), key=lambda x: -x[1]['percent'])
|
||||
# A list of the indices of the dropped scores
|
||||
@@ -308,33 +323,50 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
section_name = scores[i].section
|
||||
|
||||
percentage = earned / float(possible)
|
||||
summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index=i + self.starting_index,
|
||||
section_type=self.section_type,
|
||||
name=section_name,
|
||||
percent=percentage,
|
||||
earned=float(earned),
|
||||
possible=float(possible))
|
||||
summary_format = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})"
|
||||
summary = summary_format.format(index=i + self.starting_index,
|
||||
section_type=self.section_type,
|
||||
name=section_name,
|
||||
percent=percentage,
|
||||
earned=float(earned),
|
||||
possible=float(possible))
|
||||
else:
|
||||
percentage = 0
|
||||
summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index, section_type=self.section_type)
|
||||
summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index,
|
||||
section_type=self.section_type)
|
||||
|
||||
short_label = "{short_label} {index:02d}".format(index=i + self.starting_index, short_label=self.short_label)
|
||||
short_label = "{short_label} {index:02d}".format(index=i + self.starting_index,
|
||||
short_label=self.short_label)
|
||||
|
||||
breakdown.append({'percent': percentage, 'label': short_label, 'detail': summary, 'category': self.category})
|
||||
breakdown.append({'percent': percentage, 'label': short_label,
|
||||
'detail': summary, 'category': self.category})
|
||||
|
||||
total_percent, dropped_indices = totalWithDrops(breakdown, self.drop_count)
|
||||
total_percent, dropped_indices = total_with_drops(breakdown, self.drop_count)
|
||||
|
||||
for dropped_index in dropped_indices:
|
||||
breakdown[dropped_index]['mark'] = {'detail': "The lowest {drop_count} {section_type} scores are dropped.".format(drop_count=self.drop_count, section_type=self.section_type)}
|
||||
breakdown[dropped_index]['mark'] = {'detail': "The lowest {drop_count} {section_type} scores are dropped."
|
||||
.format(drop_count=self.drop_count, section_type=self.section_type)}
|
||||
|
||||
total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent, section_type=self.section_type)
|
||||
total_label = "{short_label} Avg".format(short_label=self.short_label)
|
||||
if len(breakdown) == 1:
|
||||
# if there is only one entry in a section, suppress the existing individual entry and the average,
|
||||
# and just display a single entry for the section. That way it acts automatically like a
|
||||
# SingleSectionGrader.
|
||||
total_detail = "{section_type} = {percent:.0%}".format(percent=total_percent,
|
||||
section_type=self.section_type)
|
||||
total_label = "{short_label}".format(short_label=self.short_label)
|
||||
breakdown = [{'percent': total_percent, 'label': total_label,
|
||||
'detail': total_detail, 'category': self.category, 'prominent': True}, ]
|
||||
else:
|
||||
total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent,
|
||||
section_type=self.section_type)
|
||||
total_label = "{short_label} Avg".format(short_label=self.short_label)
|
||||
|
||||
if self.show_only_average:
|
||||
breakdown = []
|
||||
if self.show_only_average:
|
||||
breakdown = []
|
||||
|
||||
if not self.hide_average:
|
||||
breakdown.append({'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True})
|
||||
if not self.hide_average:
|
||||
breakdown.append({'percent': total_percent, 'label': total_label,
|
||||
'detail': total_detail, 'category': self.category, 'prominent': True})
|
||||
|
||||
return {'percent': total_percent,
|
||||
'section_breakdown': breakdown,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class @Annotatable
|
||||
_debug: false
|
||||
|
||||
# selectors for the annotatable xmodule
|
||||
# selectors for the annotatable xmodule
|
||||
wrapperSelector: '.annotatable-wrapper'
|
||||
toggleAnnotationsSelector: '.annotatable-toggle-annotations'
|
||||
toggleInstructionsSelector: '.annotatable-toggle-instructions'
|
||||
instructionsSelector: '.annotatable-instructions'
|
||||
@@ -61,7 +62,7 @@ class @Annotatable
|
||||
my: 'bottom center' # of tooltip
|
||||
at: 'top center' # of target
|
||||
target: $(el) # where the tooltip was triggered (i.e. the annotation span)
|
||||
container: @$el
|
||||
container: @$(@wrapperSelector)
|
||||
adjust:
|
||||
y: -5
|
||||
show:
|
||||
@@ -75,6 +76,7 @@ class @Annotatable
|
||||
classes: 'ui-tooltip-annotatable'
|
||||
events:
|
||||
show: @onShowTip
|
||||
move: @onMoveTip
|
||||
|
||||
onClickToggleAnnotations: (e) => @toggleAnnotations()
|
||||
|
||||
@@ -87,6 +89,55 @@ class @Annotatable
|
||||
onShowTip: (event, api) =>
|
||||
event.preventDefault() if @annotationsHidden
|
||||
|
||||
onMoveTip: (event, api, position) =>
|
||||
###
|
||||
This method handles an edge case in which a tooltip is displayed above
|
||||
a non-overlapping span like this:
|
||||
|
||||
(( TOOLTIP ))
|
||||
\/
|
||||
text text text ... text text text ...... <span span span>
|
||||
<span span span>
|
||||
|
||||
The problem is that the tooltip looks disconnected from both spans, so
|
||||
we should re-position the tooltip to appear above the span.
|
||||
###
|
||||
|
||||
tip = api.elements.tooltip
|
||||
adjust_y = api.options.position?.adjust?.y || 0
|
||||
container = api.options.position?.container || $('body')
|
||||
target = api.elements.target
|
||||
|
||||
rects = $(target).get(0).getClientRects()
|
||||
is_non_overlapping = (rects?.length == 2 and rects[0].left > rects[1].right)
|
||||
|
||||
if is_non_overlapping
|
||||
# we want to choose the largest of the two non-overlapping spans and display
|
||||
# the tooltip above the center of it (see api.options.position settings)
|
||||
focus_rect = (if rects[0].width > rects[1].width then rects[0] else rects[1])
|
||||
rect_center = focus_rect.left + (focus_rect.width / 2)
|
||||
rect_top = focus_rect.top
|
||||
tip_width = $(tip).width()
|
||||
tip_height = $(tip).height()
|
||||
|
||||
# tooltip is positioned relative to its container, so we need to factor in offsets
|
||||
container_offset = $(container).offset()
|
||||
offset_left = -container_offset.left
|
||||
offset_top = $(document).scrollTop() - container_offset.top
|
||||
|
||||
tip_left = offset_left + rect_center - (tip_width / 2)
|
||||
tip_top = offset_top + rect_top - tip_height + adjust_y
|
||||
|
||||
# make sure the new tip position doesn't clip the edges of the screen
|
||||
win_width = $(window).width()
|
||||
if tip_left < offset_left
|
||||
tip_left = offset_left
|
||||
else if tip_left + tip_width > win_width + offset_left
|
||||
tip_left = win_width + offset_left - tip_width
|
||||
|
||||
# final step: update the position object (used by qtip2 to show the tip after the move event)
|
||||
$.extend position, 'left': tip_left, 'top': tip_top
|
||||
|
||||
getSpanForProblemReturn: (el) ->
|
||||
problem_id = $(@problemReturnSelector).index(el)
|
||||
@$(@spanSelector).filter("[data-problem-id='#{problem_id}']")
|
||||
|
||||
@@ -41,6 +41,11 @@ class @Problem
|
||||
@el.attr progress: response.progress_status
|
||||
@el.trigger('progressChanged')
|
||||
|
||||
forceUpdate: (response) =>
|
||||
@el.attr progress: response.progress_status
|
||||
@el.trigger('progressChanged')
|
||||
|
||||
|
||||
queueing: =>
|
||||
@queued_items = @$(".xqueue")
|
||||
@num_queued_items = @queued_items.length
|
||||
@@ -71,6 +76,7 @@ class @Problem
|
||||
|
||||
@num_queued_items = @new_queued_items.length
|
||||
if @num_queued_items == 0
|
||||
@forceUpdate response
|
||||
delete window.queuePollerID
|
||||
else
|
||||
# TODO: Some logic to dynamically adjust polling rate based on queuelen
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user