Commit 61b40626 by Chris Dodge

Merge branch 'master' of github.com:MITx/mitx into…

Merge branch 'master' of github.com:MITx/mitx into feature/cdodge/add-no-inheritable-metadata-on-verticals-to-xlint
parents dc17e933 36c27377
...@@ -34,6 +34,7 @@ load-plugins= ...@@ -34,6 +34,7 @@ load-plugins=
# multiple time (only on the command line, not in the configuration file where # multiple time (only on the command line, not in the configuration file where
# it should appear only once). # it should appear only once).
disable= disable=
# C0301: Line too long
# W0141: Used builtin function 'map' # W0141: Used builtin function 'map'
# W0142: Used * or ** magic # W0142: Used * or ** magic
# R0201: Method could be a function # R0201: Method could be a function
...@@ -42,7 +43,7 @@ disable= ...@@ -42,7 +43,7 @@ disable=
# R0903: Too few public methods (1/2) # R0903: Too few public methods (1/2)
# R0904: Too many public methods # R0904: Too many public methods
# R0913: Too many arguments # R0913: Too many arguments
W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0913 C0301,W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0913
[REPORTS] [REPORTS]
...@@ -138,7 +139,7 @@ bad-names=foo,bar,baz,toto,tutu,tata ...@@ -138,7 +139,7 @@ bad-names=foo,bar,baz,toto,tutu,tata
# Regular expression which should only match functions or classes name which do # Regular expression which should only match functions or classes name which do
# not require a docstring # not require a docstring
no-docstring-rgx=__.*__ no-docstring-rgx=(__.*__|test_.*)
[MISCELLANEOUS] [MISCELLANEOUS]
......
Feature: Advanced (manual) course policy Feature: Advanced (manual) course policy
In order to specify course policy settings for which no custom user interface exists 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 Scenario: A course author sees default advanced settings
Given I have opened a new course in Studio Given I have opened a new course in Studio
......
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
import time import time
from terrain.steps import reload_the_page 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 from nose.tools import assert_true, assert_false, assert_equal
...@@ -18,13 +19,14 @@ DISPLAY_NAME_KEY = "display_name" ...@@ -18,13 +19,14 @@ DISPLAY_NAME_KEY = "display_name"
DISPLAY_NAME_VALUE = '"Robot Super Course"' DISPLAY_NAME_VALUE = '"Robot Super Course"'
############### ACTIONS #################### ############### ACTIONS ####################
@step('I select the Advanced Settings$') @step('I select the Advanced Settings$')
def i_select_advanced_settings(step): def i_select_advanced_settings(step):
expand_icon_css = 'li.nav-course-settings i.icon-expand' expand_icon_css = 'li.nav-course-settings i.icon-expand'
if world.browser.is_element_present_by_css(expand_icon_css): 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' 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$') @step('I am on the Advanced Course Settings page in Studio$')
...@@ -35,24 +37,8 @@ def i_am_on_advanced_course_settings(step): ...@@ -35,24 +37,8 @@ def i_am_on_advanced_course_settings(step):
@step(u'I press the "([^"]*)" notification button$') @step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name): def press_the_notification_button(step, name):
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() css = 'a.%s-button' % name.lower()
wait_for(is_visible) world.css_click_at(css)
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)
@step(u'I edit the value of a policy key$') @step(u'I edit the value of a policy key$')
...@@ -61,7 +47,7 @@ def edit_the_value_of_a_policy_key(step): ...@@ -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 It is hard to figure out how to get into the CodeMirror
area, so cheat and do it from the policy key field :) 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') e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X')
...@@ -85,7 +71,7 @@ def i_see_default_advanced_settings(step): ...@@ -85,7 +71,7 @@ def i_see_default_advanced_settings(step):
@step('the settings are alphabetized$') @step('the settings are alphabetized$')
def they_are_alphabetized(step): def they_are_alphabetized(step):
key_elements = css_find(KEY_CSS) key_elements = world.css_find(KEY_CSS)
all_keys = [] all_keys = []
for key in key_elements: for key in key_elements:
all_keys.append(key.value) all_keys.append(key.value)
...@@ -118,13 +104,13 @@ def assert_policy_entries(expected_keys, expected_values): ...@@ -118,13 +104,13 @@ def assert_policy_entries(expected_keys, expected_values):
for counter in range(len(expected_keys)): for counter in range(len(expected_keys)):
index = get_index_of(expected_keys[counter]) index = get_index_of(expected_keys[counter])
assert_false(index == -1, "Could not find key: " + 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): 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 # 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: if key == expected_key:
return counter return counter
...@@ -133,14 +119,14 @@ def get_index_of(expected_key): ...@@ -133,14 +119,14 @@ def get_index_of(expected_key):
def get_display_name_value(): def get_display_name_value():
index = get_index_of(DISPLAY_NAME_KEY) 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): 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() display_name = get_display_name_value()
for count in range(len(display_name)): for count in range(len(display_name)):
e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE) e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE)
# Must delete "" before typing the JSON value # Must delete "" before typing the JSON value
e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_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")
\ No newline at end of file
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
#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()
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from lettuce.django import django_url
from nose.tools import assert_true from nose.tools import assert_true
from nose.tools import assert_equal from nose.tools import assert_equal
from selenium.webdriver.support.ui import WebDriverWait
from selenium.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.modulestore.django import _MODULESTORES, modulestore
from xmodule.templates import update_templates from xmodule.templates import update_templates
...@@ -15,14 +13,15 @@ from logging import getLogger ...@@ -15,14 +13,15 @@ from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
########### STEP HELPERS ############## ########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$') @step('I (?:visit|access|open) the Studio homepage$')
def i_visit_the_studio_homepage(step): def i_visit_the_studio_homepage(step):
# To make this go to port 8001, put # To make this go to port 8001, put
# LETTUCE_SERVER_PORT = 8001 # LETTUCE_SERVER_PORT = 8001
# in your settings.py file. # in your settings.py file.
world.browser.visit(django_url('/')) world.visit('/')
signin_css = 'a.action-signin' 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$') @step('I am logged into Studio$')
...@@ -43,12 +42,12 @@ def i_press_the_category_delete_icon(step, category): ...@@ -43,12 +42,12 @@ def i_press_the_category_delete_icon(step, category):
css = 'a.delete-button.delete-subsection-button span.delete-icon' css = 'a.delete-button.delete-subsection-button span.delete-icon'
else: else:
assert False, 'Invalid category: %s' % category assert False, 'Invalid category: %s' % category
css_click(css) world.css_click(css)
@step('I have opened a new course in Studio$') @step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step): def i_have_opened_a_new_course(step):
clear_courses() world.clear_courses()
log_into_studio() log_into_studio()
create_a_course() create_a_course()
...@@ -74,80 +73,13 @@ def create_studio_user( ...@@ -74,80 +73,13 @@ def create_studio_user(
user_profile = world.UserProfileFactory(user=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( def fill_in_course_info(
name='Robot Super Course', name='Robot Super Course',
org='MITx', org='MITx',
num='101'): num='101'):
css_fill('.new-course-name', name) world.css_fill('.new-course-name', name)
css_fill('.new-course-org', org) world.css_fill('.new-course-org', org)
css_fill('.new-course-number', num) world.css_fill('.new-course-number', num)
def log_into_studio( def log_into_studio(
...@@ -155,21 +87,22 @@ def log_into_studio( ...@@ -155,21 +87,22 @@ def log_into_studio(
email='robot+studio@edx.org', email='robot+studio@edx.org',
password='test', password='test',
is_staff=False): is_staff=False):
create_studio_user(uname=uname, email=email, is_staff=is_staff) create_studio_user(uname=uname, email=email, is_staff=is_staff)
world.browser.cookies.delete() world.browser.cookies.delete()
world.browser.visit(django_url('/')) world.visit('/')
signin_css = 'a.action-signin'
world.browser.is_element_present_by_css(signin_css, 10)
# click the signin button signin_css = 'a.action-signin'
css_click(signin_css) world.is_css_present(signin_css)
world.css_click(signin_css)
login_form = world.browser.find_by_css('form#login_form') login_form = world.browser.find_by_css('form#login_form')
login_form.find_by_name('email').fill(email) login_form.find_by_name('email').fill(email)
login_form.find_by_name('password').fill(password) login_form.find_by_name('password').fill(password)
login_form.find_by_name('submit').click() 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(): def create_a_course():
...@@ -184,26 +117,26 @@ def create_a_course(): ...@@ -184,26 +117,26 @@ def create_a_course():
world.browser.reload() world.browser.reload()
course_link_css = 'span.class-name' course_link_css = 'span.class-name'
css_click(course_link_css) world.css_click(course_link_css)
course_title_css = 'span.course-title' 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'): def add_section(name='My Section'):
link_css = 'a.new-courseware-section-button' link_css = 'a.new-courseware-section-button'
css_click(link_css) world.css_click(link_css)
name_css = 'input.new-section-name' name_css = 'input.new-section-name'
save_css = 'input.new-section-name-save' save_css = 'input.new-section-name-save'
css_fill(name_css, name) world.css_fill(name_css, name)
css_click(save_css) world.css_click(save_css)
span_css = 'span.section-name-span' 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'): def add_subsection(name='Subsection One'):
css = 'a.new-subsection-item' css = 'a.new-subsection-item'
css_click(css) world.css_click(css)
name_css = 'input.new-subsection-name-input' name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save' save_css = 'input.new-subsection-name-save'
css_fill(name_css, name) world.css_fill(name_css, name)
css_click(save_css) world.css_click(save_css)
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
#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 ...@@ -10,4 +10,4 @@ Feature: Create Course
And I fill in the new course information And I fill in the new course information
And I press the "Save" button And I press the "Save" button
Then the Courseware page has loaded in Studio 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
\ No newline at end of file
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
...@@ -6,12 +9,12 @@ from common import * ...@@ -6,12 +9,12 @@ from common import *
@step('There are no courses$') @step('There are no courses$')
def no_courses(step): def no_courses(step):
clear_courses() world.clear_courses()
@step('I click the New Course button$') @step('I click the New Course button$')
def i_click_new_course(step): 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$') @step('I fill in the new course information$')
...@@ -27,7 +30,7 @@ def i_create_a_course(step): ...@@ -27,7 +30,7 @@ def i_create_a_course(step):
@step('I click the course link in My Courses$') @step('I click the course link in My Courses$')
def i_click_the_course_link_in_my_courses(step): def i_click_the_course_link_in_my_courses(step):
course_css = 'span.class-name' course_css = 'span.class-name'
css_click(course_css) world.css_click(course_css)
############ ASSERTIONS ################### ############ ASSERTIONS ###################
...@@ -35,28 +38,28 @@ def i_click_the_course_link_in_my_courses(step): ...@@ -35,28 +38,28 @@ def i_click_the_course_link_in_my_courses(step):
@step('the Courseware page has loaded in Studio$') @step('the Courseware page has loaded in Studio$')
def courseware_page_has_loaded_in_studio(step): def courseware_page_has_loaded_in_studio(step):
course_title_css = 'span.course-title' 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$') @step('I see the course listed in My Courses$')
def i_see_the_course_in_my_courses(step): def i_see_the_course_in_my_courses(step):
course_css = 'span.class-name' 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$') @step('the course is loaded$')
def course_is_loaded(step): def course_is_loaded(step):
class_css = 'a.class-name' 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$') @step('I am on the "([^"]*)" tab$')
def i_am_on_tab(step, tab_name): def i_am_on_tab(step, tab_name):
header_css = 'div.inner-wrapper h1' 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$') @step('I see a link for adding a new section$')
def i_see_new_section_link(step): def i_see_new_section_link(step):
link_css = 'a.new-courseware-section-button' link_css = 'a.new-courseware-section-button'
assert_css_with_text(link_css, '+ New Section') assert world.css_has_text(link_css, '+ New Section')
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
from nose.tools import assert_equal from nose.tools import assert_equal
...@@ -10,7 +13,7 @@ import time ...@@ -10,7 +13,7 @@ import time
@step('I click the new section link$') @step('I click the new section link$')
def i_click_new_section_link(step): def i_click_new_section_link(step):
link_css = 'a.new-courseware-section-button' 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$') @step('I enter the section name and click save$')
...@@ -31,19 +34,19 @@ def i_have_added_new_section(step): ...@@ -31,19 +34,19 @@ def i_have_added_new_section(step):
@step('I click the Edit link for the release date$') @step('I click the Edit link for the release date$')
def i_click_the_edit_link_for_the_release_date(step): def i_click_the_edit_link_for_the_release_date(step):
button_css = 'div.section-published-date a.edit-button' 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$') @step('I save a new section release date$')
def i_save_a_new_section_release_date(step): def i_save_a_new_section_release_date(step):
date_css = 'input.start-date.date.hasDatepicker' date_css = 'input.start-date.date.hasDatepicker'
time_css = 'input.start-time.time.ui-timepicker-input' time_css = 'input.start-time.time.ui-timepicker-input'
css_fill(date_css, '12/25/2013') world.css_fill(date_css, '12/25/2013')
# hit TAB to get to the time field # hit TAB to get to the time field
e = css_find(date_css).first e = world.css_find(date_css).first
e._element.send_keys(Keys.TAB) e._element.send_keys(Keys.TAB)
css_fill(time_css, '12:00am') world.css_fill(time_css, '12:00am')
e = css_find(time_css).first e = world.css_find(time_css).first
e._element.send_keys(Keys.TAB) e._element.send_keys(Keys.TAB)
time.sleep(float(1)) time.sleep(float(1))
world.browser.click_link_by_text('Save') world.browser.click_link_by_text('Save')
...@@ -64,13 +67,13 @@ def i_see_my_section_name_with_quote_on_the_courseware_page(step): ...@@ -64,13 +67,13 @@ def i_see_my_section_name_with_quote_on_the_courseware_page(step):
@step('I click to edit the section name$') @step('I click to edit the section name$')
def i_click_to_edit_section_name(step): 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$') @step('I see the complete section name with a quote in the editor$')
def i_see_complete_section_name_with_quote_in_editor(step): def i_see_complete_section_name_with_quote_in_editor(step):
css = '.edit-section-name' 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"') assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
...@@ -85,7 +88,7 @@ def i_see_a_release_date_for_my_section(step): ...@@ -85,7 +88,7 @@ def i_see_a_release_date_for_my_section(step):
import re import re
css = 'span.published-status' 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 status_text = world.browser.find_by_css(css).text
# e.g. 11/06/2012 at 16:25 # e.g. 11/06/2012 at 16:25
...@@ -99,20 +102,20 @@ def i_see_a_release_date_for_my_section(step): ...@@ -99,20 +102,20 @@ def i_see_a_release_date_for_my_section(step):
@step('I see a link to create a new subsection$') @step('I see a link to create a new subsection$')
def i_see_a_link_to_create_a_new_subsection(step): def i_see_a_link_to_create_a_new_subsection(step):
css = 'a.new-subsection-item' 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$') @step('the section release date picker is not visible$')
def the_section_release_date_picker_not_visible(step): def the_section_release_date_picker_not_visible(step):
css = 'div.edit-subsection-publish-settings' 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$') @step('the section release date is updated$')
def the_section_release_date_is_updated(step): def the_section_release_date_is_updated(step):
css = 'span.published-status' css = 'span.published-status'
status_text = world.browser.find_by_css(css).text status_text = world.css_text(css)
assert_equal(status_text,'Will Release: 12/25/2013 at 12:00am') assert_equal(status_text, 'Will Release: 12/25/2013 at 12:00am')
############ HELPER METHODS ################### ############ HELPER METHODS ###################
...@@ -120,10 +123,10 @@ def the_section_release_date_is_updated(step): ...@@ -120,10 +123,10 @@ def the_section_release_date_is_updated(step):
def save_section_name(name): def save_section_name(name):
name_css = '.new-section-name' name_css = '.new-section-name'
save_css = '.new-section-name-save' save_css = '.new-section-name-save'
css_fill(name_css, name) world.css_fill(name_css, name)
css_click(save_css) world.css_click(save_css)
def see_my_section_on_the_courseware_page(name): def see_my_section_on_the_courseware_page(name):
section_css = 'span.section-name-span' section_css = 'span.section-name-span'
assert_css_with_text(section_css, name) assert world.css_has_text(section_css, name)
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
...@@ -17,9 +20,10 @@ def i_press_the_button_on_the_registration_form(step): ...@@ -17,9 +20,10 @@ def i_press_the_button_on_the_registration_form(step):
submit_css = 'form#register_form button#submit' submit_css = 'form#register_form button#submit'
# Workaround for click not working on ubuntu # Workaround for click not working on ubuntu
# for some unknown reason. # for some unknown reason.
e = css_find(submit_css) e = world.css_find(submit_css)
e.type(' ') e.type(' ')
@step('I should see be on the studio home page$') @step('I should see be on the studio home page$')
def i_should_see_be_on_the_studio_home_page(step): def i_should_see_be_on_the_studio_home_page(step):
assert world.browser.find_by_css('div.inner-wrapper') assert world.browser.find_by_css('div.inner-wrapper')
......
Feature: Overview Toggle Section Feature: Overview Toggle Section
In order to quickly view the details of a course's section or to scan the inventory of sections In order to quickly view the details of a course's section or to scan the inventory of sections
As a course author As a course author
I want to toggle the visibility of each section's subsection details in the overview listing 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 Scenario: The default layout for the overview page is to show sections in expanded view
Given I have a course with multiple sections Given I have a course with multiple sections
When I navigate to the course overview page When I navigate to the course overview page
Then I see the "Collapse All Sections" link Then I see the "Collapse All Sections" link
And all sections are expanded And all sections are expanded
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 Given I have a course with no sections
When I navigate to the course overview page When I navigate to the course overview page
Then I do not see the "Collapse All Sections" link Then I do not see the "Collapse All Sections" link
Scenario: Collapse link appears after creating first section of a course Scenario: Collapse link appears after creating first section of a course
Given I have a course with no sections Given I have a course with no sections
When I navigate to the course overview page When I navigate to the course overview page
And I add a section And I add a section
Then I see the "Collapse All Sections" link Then I see the "Collapse All Sections" link
And all sections are expanded And all sections are expanded
@skip-phantom @skip-phantom
Scenario: Collapse link is not removed after last section of a course is deleted Scenario: Collapse link is not removed after last section of a course is deleted
Given I have a course with 1 section Given I have a course with 1 section
And I navigate to the course overview page And I navigate to the course overview page
When I press the "section" delete icon When I press the "section" delete icon
And I confirm the alert And I confirm the alert
Then I see the "Collapse All Sections" link Then I see the "Collapse All Sections" link
......
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
from nose.tools import assert_true, assert_false, assert_equal from nose.tools import assert_true, assert_false, assert_equal
...@@ -8,13 +11,13 @@ logger = getLogger(__name__) ...@@ -8,13 +11,13 @@ logger = getLogger(__name__)
@step(u'I have a course with no sections$') @step(u'I have a course with no sections$')
def have_a_course(step): def have_a_course(step):
clear_courses() world.clear_courses()
course = world.CourseFactory.create() course = world.CourseFactory.create()
@step(u'I have a course with 1 section$') @step(u'I have a course with 1 section$')
def have_a_course_with_1_section(step): def have_a_course_with_1_section(step):
clear_courses() world.clear_courses()
course = world.CourseFactory.create() course = world.CourseFactory.create()
section = world.ItemFactory.create(parent_location=course.location) section = world.ItemFactory.create(parent_location=course.location)
subsection1 = world.ItemFactory.create( subsection1 = world.ItemFactory.create(
...@@ -25,7 +28,7 @@ def have_a_course_with_1_section(step): ...@@ -25,7 +28,7 @@ def have_a_course_with_1_section(step):
@step(u'I have a course with multiple sections$') @step(u'I have a course with multiple sections$')
def have_a_course_with_two_sections(step): def have_a_course_with_two_sections(step):
clear_courses() world.clear_courses()
course = world.CourseFactory.create() course = world.CourseFactory.create()
section = world.ItemFactory.create(parent_location=course.location) section = world.ItemFactory.create(parent_location=course.location)
subsection1 = world.ItemFactory.create( subsection1 = world.ItemFactory.create(
...@@ -49,7 +52,7 @@ def have_a_course_with_two_sections(step): ...@@ -49,7 +52,7 @@ def have_a_course_with_two_sections(step):
def navigate_to_the_course_overview_page(step): def navigate_to_the_course_overview_page(step):
log_into_studio(is_staff=True) log_into_studio(is_staff=True)
course_locator = '.class-name' 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') @step(u'I navigate to the courseware page of a course with multiple sections')
...@@ -66,44 +69,44 @@ def i_add_a_section(step): ...@@ -66,44 +69,44 @@ def i_add_a_section(step):
@step(u'I click the "([^"]*)" link$') @step(u'I click the "([^"]*)" link$')
def i_click_the_text_span(step, text): def i_click_the_text_span(step, text):
span_locator = '.toggle-button-sections span' 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 # first make sure that the expand/collapse text is the one you expected
assert_equal(world.browser.find_by_css(span_locator).value, text) 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$') @step(u'I collapse the first section$')
def i_collapse_a_section(step): def i_collapse_a_section(step):
collapse_locator = 'section.courseware-section a.collapse' collapse_locator = 'section.courseware-section a.collapse'
css_click(collapse_locator) world.css_click(collapse_locator)
@step(u'I expand the first section$') @step(u'I expand the first section$')
def i_expand_a_section(step): def i_expand_a_section(step):
expand_locator = 'section.courseware-section a.expand' expand_locator = 'section.courseware-section a.expand'
css_click(expand_locator) world.css_click(expand_locator)
@step(u'I see the "([^"]*)" link$') @step(u'I see the "([^"]*)" link$')
def i_see_the_span_with_text(step, text): def i_see_the_span_with_text(step, text):
span_locator = '.toggle-button-sections span' span_locator = '.toggle-button-sections span'
assert_true(world.browser.is_element_present_by_css(span_locator, 5)) assert_true(world.is_css_present(span_locator))
assert_equal(world.browser.find_by_css(span_locator).value, text) assert_equal(world.css_find(span_locator).value, text)
assert_true(world.browser.find_by_css(span_locator).visible) assert_true(world.css_visible(span_locator))
@step(u'I do not see the "([^"]*)" link$') @step(u'I do not see the "([^"]*)" link$')
def i_do_not_see_the_span_with_text(step, text): def i_do_not_see_the_span_with_text(step, text):
# Note that the span will exist on the page but not be visible # Note that the span will exist on the page but not be visible
span_locator = '.toggle-button-sections span' span_locator = '.toggle-button-sections span'
assert_true(world.browser.is_element_present_by_css(span_locator)) assert_true(world.is_css_present(span_locator))
assert_false(world.browser.find_by_css(span_locator).visible) assert_false(world.css_visible(span_locator))
@step(u'all sections are expanded$') @step(u'all sections are expanded$')
def all_sections_are_expanded(step): def all_sections_are_expanded(step):
subsection_locator = 'div.subsection-list' subsection_locator = 'div.subsection-list'
subsections = world.browser.find_by_css(subsection_locator) subsections = world.css_find(subsection_locator)
for s in subsections: for s in subsections:
assert_true(s.visible) assert_true(s.visible)
...@@ -111,6 +114,6 @@ def all_sections_are_expanded(step): ...@@ -111,6 +114,6 @@ def all_sections_are_expanded(step):
@step(u'all sections are collapsed$') @step(u'all sections are collapsed$')
def all_sections_are_expanded(step): def all_sections_are_expanded(step):
subsection_locator = 'div.subsection-list' subsection_locator = 'div.subsection-list'
subsections = world.browser.find_by_css(subsection_locator) subsections = world.css_find(subsection_locator)
for s in subsections: for s in subsections:
assert_false(s.visible) assert_false(s.visible)
...@@ -17,6 +17,14 @@ Feature: Create Subsection ...@@ -17,6 +17,14 @@ Feature: Create Subsection
And I click to edit the subsection name And I click to edit the subsection name
Then I see the complete subsection name with a quote in the editor Then I see the complete subsection name with a quote in the editor
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
@skip-phantom @skip-phantom
Scenario: Delete a subsection Scenario: Delete a subsection
Given I have opened a new course section in Studio Given I have opened a new course section in Studio
......
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
from nose.tools import assert_equal from nose.tools import assert_equal
...@@ -7,7 +10,7 @@ from nose.tools import assert_equal ...@@ -7,7 +10,7 @@ from nose.tools import assert_equal
@step('I have opened a new course section in Studio$') @step('I have opened a new course section in Studio$')
def i_have_opened_a_new_course_section(step): def i_have_opened_a_new_course_section(step):
clear_courses() world.clear_courses()
log_into_studio() log_into_studio()
create_a_course() create_a_course()
add_section() add_section()
...@@ -15,8 +18,7 @@ def i_have_opened_a_new_course_section(step): ...@@ -15,8 +18,7 @@ def i_have_opened_a_new_course_section(step):
@step('I click the New Subsection link') @step('I click the New Subsection link')
def i_click_the_new_subsection_link(step): def i_click_the_new_subsection_link(step):
css = 'a.new-subsection-item' world.css_click('a.new-subsection-item')
css_click(css)
@step('I enter the subsection name and click save$') @step('I enter the subsection name and click save$')
...@@ -31,14 +33,14 @@ def i_save_subsection_name_with_quote(step): ...@@ -31,14 +33,14 @@ def i_save_subsection_name_with_quote(step):
@step('I click to edit the subsection name$') @step('I click to edit the subsection name$')
def i_click_to_edit_subsection_name(step): 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$') @step('I see the complete subsection name with a quote in the editor$')
def i_see_complete_subsection_name_with_quote_in_editor(step): def i_see_complete_subsection_name_with_quote_in_editor(step):
css = '.subsection-display-name-input' css = '.subsection-display-name-input'
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, 'Subsection With "Quote"') assert_equal(world.css_find(css).value, 'Subsection With "Quote"')
@step('I have added a new subsection$') @step('I have added a new subsection$')
...@@ -46,6 +48,17 @@ def i_have_added_a_new_subsection(step): ...@@ -46,6 +48,17 @@ def i_have_added_a_new_subsection(step):
add_subsection() add_subsection()
@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 ################### ############ ASSERTIONS ###################
...@@ -70,11 +83,12 @@ def the_subsection_does_not_exist(step): ...@@ -70,11 +83,12 @@ def the_subsection_does_not_exist(step):
def save_subsection_name(name): def save_subsection_name(name):
name_css = 'input.new-subsection-name-input' name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save' save_css = 'input.new-subsection-name-save'
css_fill(name_css, name) world.css_fill(name_css, name)
css_click(save_css) world.css_click(save_css)
def see_subsection_name(name): def see_subsection_name(name):
css = 'span.subsection-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' css = 'span.subsection-name-value'
assert_css_with_text(css, name) assert world.css_has_text(css, name)
""" 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)
\ No newline at end of file
...@@ -37,6 +37,14 @@ TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) ...@@ -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['default']['OPTIONS']['fs_root'] = path('common/test/data')
TEST_DATA_MODULESTORE['direct']['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) @override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
class ContentStoreToyCourseTest(ModuleStoreTestCase): class ContentStoreToyCourseTest(ModuleStoreTestCase):
...@@ -77,6 +85,43 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -77,6 +85,43 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
def test_edit_unit_full(self): def test_edit_unit_full(self):
self.check_edit_unit('full') 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): def test_static_tab_reordering(self):
import_from_xml(modulestore(), 'common/test/data/', ['full']) import_from_xml(modulestore(), 'common/test/data/', ['full'])
...@@ -149,8 +194,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -149,8 +194,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# make sure the parent no longer points to the child object which was deleted # make sure the parent no longer points to the child object which was deleted
self.assertFalse(sequential.location.url() in chapter.children) self.assertFalse(sequential.location.url() in chapter.children)
def test_about_overrides(self): 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 This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html
...@@ -211,6 +254,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -211,6 +254,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()}))
self.assertEqual(resp.status_code, 200) 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): def test_delete_course(self):
import_from_xml(modulestore(), 'common/test/data/', ['full']) import_from_xml(modulestore(), 'common/test/data/', ['full'])
...@@ -311,6 +358,28 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -311,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 # note, we know the link it should be because that's what in the 'full' course in the test data
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
def test_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): def test_export_course_with_unknown_metadata(self):
module_store = modulestore('direct') module_store = modulestore('direct')
content_store = contentstore() content_store = contentstore()
...@@ -532,7 +601,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -532,7 +601,7 @@ class ContentStoreTest(ModuleStoreTestCase):
module_store.update_children(parent.location, parent.children + [new_component_location.url()]) module_store.update_children(parent.location, parent.children + [new_component_location.url()])
# flush the cache # 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) new_module = module_store.get_item(new_component_location)
# check for grace period definition which should be defined at the course level # check for grace period definition which should be defined at the course level
...@@ -547,7 +616,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -547,7 +616,7 @@ class ContentStoreTest(ModuleStoreTestCase):
module_store.update_metadata(new_module.location, own_metadata(new_module)) module_store.update_metadata(new_module.location, own_metadata(new_module))
# flush the cache and refetch # 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) new_module = module_store.get_item(new_component_location)
self.assertEqual(timedelta(1), new_module.lms.graceperiod) self.assertEqual(timedelta(1), new_module.lms.graceperiod)
......
import datetime import datetime
import json import json
import copy import copy
from util import converters
from util.converters import jsdate_to_time
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test.client import Client from django.test.client import Client
...@@ -15,33 +13,13 @@ from models.settings.course_details import (CourseDetails, ...@@ -15,33 +13,13 @@ from models.settings.course_details import (CourseDetails,
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from django.test import TestCase
from .utils import ModuleStoreTestCase from .utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from models.settings.course_metadata import CourseMetadata from models.settings.course_metadata import CourseMetadata
from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.fields import Date
# 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))
class CourseTestCase(ModuleStoreTestCase): class CourseTestCase(ModuleStoreTestCase):
def setUp(self): def setUp(self):
...@@ -104,7 +82,7 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -104,7 +82,7 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(jsondetails['effort'], "effort somehow initialized") self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
def test_update_and_fetch(self): 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 = CourseDetails.fetch(self.course_location)
jsondetails.syllabus = "<a href='foo'>bar</a>" jsondetails.syllabus = "<a href='foo'>bar</a>"
# encode - decode to convert date fields and other data which changes form # encode - decode to convert date fields and other data which changes form
...@@ -170,19 +148,26 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -170,19 +148,26 @@ class CourseDetailsViewTest(CourseTestCase):
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
self.assertEqual(details['effort'], encoded['effort'], context + " efforts 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): def compare_date_fields(self, details, encoded, context, field):
if details[field] is not None: if details[field] is not None:
date = Date()
if field in encoded and encoded[field] is not None: if field in encoded and encoded[field] is not None:
encoded_encoded = jsdate_to_time(encoded[field]) encoded_encoded = date.from_json(encoded[field])
dt1 = ConvertersTestCase.struct_to_datetime(encoded_encoded) dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded)
if isinstance(details[field], datetime.datetime): if isinstance(details[field], datetime.datetime):
dt2 = details[field] dt2 = details[field]
else: else:
details_encoded = jsdate_to_time(details[field]) details_encoded = date.from_json(details[field])
dt2 = ConvertersTestCase.struct_to_datetime(details_encoded) 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) self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context)
else: else:
self.fail(field + " missing from encoded but in details at " + context) self.fail(field + " missing from encoded but in details at " + context)
...@@ -269,7 +254,7 @@ class CourseMetadataEditingTest(CourseTestCase): ...@@ -269,7 +254,7 @@ class CourseMetadataEditingTest(CourseTestCase):
CourseTestCase.setUp(self) CourseTestCase.setUp(self)
# add in the full class too # add in the full class too
import_from_xml(modulestore(), 'common/test/data/', ['full']) 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): def test_fetch_initial_fields(self):
......
'''unit tests for course_info views and models.'''
from contentstore.tests.test_course_settings import CourseTestCase from contentstore.tests.test_course_settings import CourseTestCase
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
import json import json
from webob.exc import HTTPServerError
from django.http import HttpResponseBadRequest
class CourseUpdateTest(CourseTestCase): class CourseUpdateTest(CourseTestCase):
'''The do all and end all of unit test cases.'''
def test_course_update(self): def test_course_update(self):
'''Go through each interface and ensure it works.'''
# first get the update to force the creation # first get the update to force the creation
url = reverse('course_info', url = reverse('course_info',
kwargs={'org': self.course_location.org, kwargs={'org': self.course_location.org,
...@@ -18,9 +19,10 @@ class CourseUpdateTest(CourseTestCase): ...@@ -18,9 +19,10 @@ class CourseUpdateTest(CourseTestCase):
content = init_content + '</iframe>' content = init_content + '</iframe>'
payload = {'content': content, payload = {'content': content,
'date': 'January 8, 2013'} 'date': 'January 8, 2013'}
url = reverse('course_info_json', kwargs={'org': self.course_location.org, url = reverse('course_info_json',
'course': self.course_location.course, kwargs={'org': self.course_location.org,
'provided_id': ''}) 'course': self.course_location.course,
'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json") resp = self.client.post(url, json.dumps(payload), "application/json")
...@@ -90,14 +92,22 @@ class CourseUpdateTest(CourseTestCase): ...@@ -90,14 +92,22 @@ class CourseUpdateTest(CourseTestCase):
'course': self.course_location.course, 'course': self.course_location.course,
'provided_id': ''}) 'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json")
payload = json.loads(resp.content)
self.assertContains( self.assertContains(
self.client.post(url, json.dumps(payload), "application/json"), self.client.post(url, json.dumps(payload), "application/json"),
'<garbage') '<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 # now try to delete a non-existent update
url = reverse('course_info_json', kwargs={'org': self.course_location.org, url = reverse('course_info_json', kwargs={'org': self.course_location.org,
'course': self.course_location.course, 'course': self.course_location.course,
......
from contentstore import utils """ Tests for utils. """
from contentstore import utils
import mock import mock
from django.test import TestCase from django.test import TestCase
from xmodule.modulestore.tests.factories import CourseFactory
from .utils import ModuleStoreTestCase
class LMSLinksTestCase(TestCase): class LMSLinksTestCase(TestCase):
""" Tests for LMS links. """
def about_page_test(self): def about_page_test(self):
""" Get URL for about page. """
location = 'i4x', 'mitX', '101', 'course', 'test' location = 'i4x', 'mitX', '101', 'course', 'test'
utils.get_course_id = mock.Mock(return_value="mitX/101/test") utils.get_course_id = mock.Mock(return_value="mitX/101/test")
link = utils.get_lms_link_for_about_page(location) link = utils.get_lms_link_for_about_page(location)
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about") 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' location = 'i4x', 'mitX', '101', 'vertical', 'contacting_us'
utils.get_course_id = mock.Mock(return_value="mitX/101/test") utils.get_course_id = mock.Mock(return_value="mitX/101/test")
link = utils.get_lms_link_for_item(location, False) 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") 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) 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)
)
\ No newline at end of file
'''
Utilities for contentstore tests
'''
#pylint: disable=W0603
import json import json
import copy import copy
from uuid import uuid4 from uuid import uuid4
...@@ -17,36 +23,89 @@ class ModuleStoreTestCase(TestCase): ...@@ -17,36 +23,89 @@ class ModuleStoreTestCase(TestCase):
collection with templates before running the TestCase collection with templates before running the TestCase
and drops it they are finished. """ and drops it they are finished. """
def _pre_setup(self): @staticmethod
super(ModuleStoreTestCase, self)._pre_setup() 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 # Use a uuid to differentiate
# the mongo collections on jenkins. # the mongo collections on jenkins.
self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE) cls.orig_modulestore = copy.deepcopy(settings.MODULESTORE)
self.test_MODULESTORE = self.orig_MODULESTORE test_modulestore = cls.orig_modulestore
self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex test_modulestore['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex 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()"
xmodule.modulestore.django._MODULESTORES = {} 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): def _post_teardown(self):
# Make sure you flush out the modulestore. '''
# Drop the collection at the end of the test, Flush everything we created except the templates
# otherwise there will be lingering collections leftover '''
# from executing the tests. # Flush anything that is not a template
xmodule.modulestore.django._MODULESTORES = {} ModuleStoreTestCase.flush_mongo_except_templates()
xmodule.modulestore.django.modulestore().collection.drop()
settings.MODULESTORE = self.orig_MODULESTORE
# Call superclass implementation
super(ModuleStoreTestCase, self)._post_teardown() super(ModuleStoreTestCase, self)._post_teardown()
......
import logging
from django.conf import settings from django.conf import settings
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError 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'] 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): def get_modulestore(location):
""" """
...@@ -136,7 +141,7 @@ def compute_unit_state(unit): ...@@ -136,7 +141,7 @@ def compute_unit_state(unit):
'private' content is editabled and not visible in the LMS 'private' content is editabled and not visible in the LMS
""" """
if unit.cms.is_draft: if getattr(unit, 'is_draft', False):
try: try:
modulestore('direct').get_item(unit.location) modulestore('direct').get_item(unit.location)
return UnitState.draft return UnitState.draft
...@@ -158,3 +163,67 @@ def update_item(location, value): ...@@ -158,3 +163,67 @@ def update_item(location, value):
get_modulestore(location).delete_item(location) get_modulestore(location).delete_item(location)
else: else:
get_modulestore(location).update_item(location, value) 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
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
...@@ -6,9 +5,9 @@ import json ...@@ -6,9 +5,9 @@ import json
from json.encoder import JSONEncoder from json.encoder import JSONEncoder
import time import time
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from util.converters import jsdate_to_time, time_to_date
from models.settings import course_grading from models.settings import course_grading
from contentstore.utils import update_item from contentstore.utils import update_item
from xmodule.fields import Date
import re import re
import logging import logging
...@@ -81,8 +80,14 @@ class CourseDetails(object): ...@@ -81,8 +80,14 @@ class CourseDetails(object):
dirty = False 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: if 'start_date' in jsondict:
converted = jsdate_to_time(jsondict['start_date']) converted = date.from_json(jsondict['start_date'])
else: else:
converted = None converted = None
if converted != descriptor.start: if converted != descriptor.start:
...@@ -90,7 +95,7 @@ class CourseDetails(object): ...@@ -90,7 +95,7 @@ class CourseDetails(object):
descriptor.start = converted descriptor.start = converted
if 'end_date' in jsondict: if 'end_date' in jsondict:
converted = jsdate_to_time(jsondict['end_date']) converted = date.from_json(jsondict['end_date'])
else: else:
converted = None converted = None
...@@ -99,7 +104,7 @@ class CourseDetails(object): ...@@ -99,7 +104,7 @@ class CourseDetails(object):
descriptor.end = converted descriptor.end = converted
if 'enrollment_start' in jsondict: if 'enrollment_start' in jsondict:
converted = jsdate_to_time(jsondict['enrollment_start']) converted = date.from_json(jsondict['enrollment_start'])
else: else:
converted = None converted = None
...@@ -108,7 +113,7 @@ class CourseDetails(object): ...@@ -108,7 +113,7 @@ class CourseDetails(object):
descriptor.enrollment_start = converted descriptor.enrollment_start = converted
if 'enrollment_end' in jsondict: if 'enrollment_end' in jsondict:
converted = jsdate_to_time(jsondict['enrollment_end']) converted = date.from_json(jsondict['enrollment_end'])
else: else:
converted = None converted = None
...@@ -178,6 +183,6 @@ class CourseSettingsEncoder(json.JSONEncoder): ...@@ -178,6 +183,6 @@ class CourseSettingsEncoder(json.JSONEncoder):
elif isinstance(obj, Location): elif isinstance(obj, Location):
return obj.dict() return obj.dict()
elif isinstance(obj, time.struct_time): elif isinstance(obj, time.struct_time):
return time_to_date(obj) return Date().to_json(obj)
else: else:
return JSONEncoder.default(self, obj) return JSONEncoder.default(self, obj)
from xmodule.modulestore import Location from xmodule.modulestore import Location
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
import re
from util import converters
from datetime import timedelta from datetime import timedelta
......
...@@ -3,19 +3,24 @@ from contentstore.utils import get_modulestore ...@@ -3,19 +3,24 @@ from contentstore.utils import get_modulestore
from xmodule.x_module import XModuleDescriptor from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xblock.core import Scope from xblock.core import Scope
from xmodule.course_module import CourseDescriptor
import copy
class CourseMetadata(object): class CourseMetadata(object):
''' '''
For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones. For CRUD operations on metadata fields which do not have specific editors
The objects have no predefined attrs but instead are obj encodings of the editable metadata. 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 @classmethod
def fetch(cls, course_location): 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): if not isinstance(course_location, Location):
course_location = Location(course_location) course_location = Location(course_location)
...@@ -29,12 +34,12 @@ class CourseMetadata(object): ...@@ -29,12 +34,12 @@ class CourseMetadata(object):
continue continue
if field.name not in cls.FILTERED_LIST: if field.name not in cls.FILTERED_LIST:
course[field.name] = field.read_from(descriptor) course[field.name] = field.read_json(descriptor)
return course return course
@classmethod @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. Decode the json into CourseMetadata and save any changed attrs to the db.
...@@ -43,30 +48,40 @@ class CourseMetadata(object): ...@@ -43,30 +48,40 @@ class CourseMetadata(object):
descriptor = get_modulestore(course_location).get_item(course_location) descriptor = get_modulestore(course_location).get_item(course_location)
dirty = False 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(): for k, v in jsondict.iteritems():
# should it be an error if one of the filtered list items is in the payload? # 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 continue
if hasattr(descriptor, k) and getattr(descriptor, k) != v: if hasattr(descriptor, k) and getattr(descriptor, k) != v:
dirty = True 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: elif hasattr(descriptor.lms, k) and getattr(descriptor.lms, k) != k:
dirty = True dirty = True
setattr(descriptor.lms, k, v) value = getattr(CourseDescriptor.lms, k).from_json(v)
setattr(descriptor.lms, k, value)
if dirty: 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 # Could just generate and return a course obj w/o doing any db reads,
# it persisted correctly # but I put the reads in as a means to confirm it persisted correctly
return cls.fetch(course_location) return cls.fetch(course_location)
@classmethod @classmethod
def delete_key(cls, course_location, payload): 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) descriptor = get_modulestore(course_location).get_item(course_location)
...@@ -76,6 +91,7 @@ class CourseMetadata(object): ...@@ -76,6 +91,7 @@ class CourseMetadata(object):
elif hasattr(descriptor.lms, key): elif hasattr(descriptor.lms, key):
delattr(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) return cls.fetch(course_location)
...@@ -113,6 +113,7 @@ TEMPLATE_LOADERS = ( ...@@ -113,6 +113,7 @@ TEMPLATE_LOADERS = (
MIDDLEWARE_CLASSES = ( MIDDLEWARE_CLASSES = (
'contentserver.middleware.StaticContentServer', 'contentserver.middleware.StaticContentServer',
'request_cache.middleware.RequestCache',
'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
......
...@@ -112,6 +112,10 @@ CACHE_TIMEOUT = 0 ...@@ -112,6 +112,10 @@ CACHE_TIMEOUT = 0
# Dummy secret key for dev # Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' 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 ################################# ################################ DEBUG TOOLBAR #################################
INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo') INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo')
MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware', MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
...@@ -142,4 +146,4 @@ DEBUG_TOOLBAR_CONFIG = { ...@@ -142,4 +146,4 @@ DEBUG_TOOLBAR_CONFIG = {
# To see stacktraces for MongoDB queries, set this to True. # To see stacktraces for MongoDB queries, set this to True.
# Stacktraces slow down page loads drastically (for pages with lots of queries). # Stacktraces slow down page loads drastically (for pages with lots of queries).
DEBUG_TOOLBAR_MONGO_STACKTRACES = False DEBUG_TOOLBAR_MONGO_STACKTRACES = True
...@@ -58,6 +58,10 @@ MODULESTORE = { ...@@ -58,6 +58,10 @@ MODULESTORE = {
'direct': { 'direct': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': modulestore_options 'OPTIONS': modulestore_options
},
'draft': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'OPTIONS': modulestore_options
} }
} }
......
from dogapi import dog_http_api, dog_stats_api from dogapi import dog_http_api, dog_stats_api
from django.conf import settings from django.conf import settings
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from request_cache.middleware import RequestCache
from django.core.cache import get_cache, InvalidCacheBackendError from django.core.cache import get_cache, InvalidCacheBackendError
cache = get_cache('mongo_metadata_inheritance') cache = get_cache('mongo_metadata_inheritance')
for store_name in settings.MODULESTORE: for store_name in settings.MODULESTORE:
store = modulestore(store_name) 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'): if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API dog_http_api.api_key = settings.DATADOG_API
......
<% 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">&#x25BE;</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">&#x2713;</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>
\ No newline at end of file
...@@ -167,7 +167,7 @@ class CMS.Views.UnitEdit extends Backbone.View ...@@ -167,7 +167,7 @@ class CMS.Views.UnitEdit extends Backbone.View
class CMS.Views.UnitEdit.NameEdit extends Backbone.View class CMS.Views.UnitEdit.NameEdit extends Backbone.View
events: events:
"keyup .unit-display-name-input": "saveName" 'change .unit-display-name-input': 'saveName'
initialize: => initialize: =>
@model.on('change:metadata', @render) @model.on('change:metadata', @render)
...@@ -190,29 +190,10 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View ...@@ -190,29 +190,10 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View
# Treat the metadata dictionary as immutable # Treat the metadata dictionary as immutable
metadata = $.extend({}, @model.get('metadata')) metadata = $.extend({}, @model.get('metadata'))
metadata.display_name = @$('.unit-display-name-input').val() 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) $('.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 class CMS.Views.UnitEdit.LocationState extends Backbone.View
initialize: => initialize: =>
@model.on('change:state', @render) @model.on('change:state', @render)
......
...@@ -14,10 +14,6 @@ $(document).ready(function () { ...@@ -14,10 +14,6 @@ $(document).ready(function () {
// scopes (namely the course-info tab) // scopes (namely the course-info tab)
window.$modalCover = $modalCover; 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); $body.append($modalCover);
$newComponentItem = $('.new-component-item'); $newComponentItem = $('.new-component-item');
$newComponentTypePicker = $('.new-component'); $newComponentTypePicker = $('.new-component');
...@@ -76,10 +72,7 @@ $(document).ready(function () { ...@@ -76,10 +72,7 @@ $(document).ready(function () {
}); });
// general link management - new window/tab // general link management - new window/tab
$('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').click(function (e) { $('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').bind('click', linkNewWindow);
window.open($(this).attr('href'));
e.preventDefault();
});
// general link management - lean modal window // 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' }); $('a[rel="modal"]').attr('title', 'This link will open in a modal window').leanModal({overlay: 0.50, closeButton: '.action-modal-close' });
...@@ -87,6 +80,12 @@ $(document).ready(function () { ...@@ -87,6 +80,12 @@ $(document).ready(function () {
(e).preventDefault(); (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 // toggling overview section details
$(function () { $(function () {
if ($('.courseware-section').length > 0) { if ($('.courseware-section').length > 0) {
...@@ -95,9 +94,9 @@ $(document).ready(function () { ...@@ -95,9 +94,9 @@ $(document).ready(function () {
}); });
$('.toggle-button-sections').bind('click', toggleSections); $('.toggle-button-sections').bind('click', toggleSections);
// autosave when a field is updated on the subsection page // autosave when leaving input field
$body.on('keyup', '.subsection-display-name-input, .unit-subtitle, .policy-list-value', checkForNewValue); $body.on('change', '.subsection-display-name-input', saveSubsection);
$('.subsection-display-name-input, .unit-subtitle, .policy-list-name, .policy-list-value').each(function (i) { $('.subsection-display-name-input').each(function () {
this.val = $(this).val(); this.val = $(this).val();
}); });
$("#start_date, #start_time, #due_date, #due_time").bind('change', autosaveInput); $("#start_date, #start_time, #due_date, #due_time").bind('change', autosaveInput);
...@@ -113,11 +112,6 @@ $(document).ready(function () { ...@@ -113,11 +112,6 @@ $(document).ready(function () {
// add new/delete subsection // add new/delete subsection
$('.new-subsection-item').bind('click', addNewSubsection); $('.new-subsection-item').bind('click', addNewSubsection);
$('.delete-subsection-button').bind('click', deleteSubsection); $('.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); $('.sync-date').bind('click', syncReleaseDate);
...@@ -156,10 +150,39 @@ $(document).ready(function () { ...@@ -156,10 +150,39 @@ $(document).ready(function () {
}); });
}); });
// function collapseAll(e) { function smoothScrollLink(e) {
// $('.branch').addClass('collapsed'); (e).preventDefault();
// $('.expand-collapse-icon').removeClass('collapse').addClass('expand');
// } $.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) { function toggleSections(e) {
e.preventDefault(); e.preventDefault();
...@@ -219,56 +242,6 @@ function syncReleaseDate(e) { ...@@ -219,56 +242,6 @@ function syncReleaseDate(e) {
$("#start_time").val(""); $("#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, format) {
var edxTimeStr = null; var edxTimeStr = null;
...@@ -277,7 +250,7 @@ function getEdxTimeFromDateTimeVals(date_val, time_val, format) { ...@@ -277,7 +250,7 @@ function getEdxTimeFromDateTimeVals(date_val, time_val, format) {
time_val = '00:00'; time_val = '00:00';
// Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing // 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); var date = Date.parse(date_val + " " + time_val);
if (format == null) if (format == null)
format = 'yyyy-MM-ddTHH:mm'; format = 'yyyy-MM-ddTHH:mm';
...@@ -294,32 +267,8 @@ function getEdxTimeFromDateTimeInputs(date_id, time_id, format) { ...@@ -294,32 +267,8 @@ function getEdxTimeFromDateTimeInputs(date_id, time_id, format) {
return getEdxTimeFromDateTimeVals(input_date, input_time, format); 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);
}
}
function autosaveInput(e) { function autosaveInput(e) {
var self = this;
if (this.saveTimer) { if (this.saveTimer) {
clearTimeout(this.saveTimer); clearTimeout(this.saveTimer);
} }
...@@ -327,11 +276,12 @@ function autosaveInput(e) { ...@@ -327,11 +276,12 @@ function autosaveInput(e) {
this.saveTimer = setTimeout(function () { this.saveTimer = setTimeout(function () {
$changedInput = $(e.target); $changedInput = $(e.target);
saveSubsection(); saveSubsection();
this.saveTimer = null; self.saveTimer = null;
}, 500); }, 500);
} }
function saveSubsection() { 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')) { if ($changedInput && !$changedInput.hasClass('no-spinner')) {
$spinner.css({ $spinner.css({
'position': 'absolute', 'position': 'absolute',
...@@ -354,20 +304,6 @@ function saveSubsection() { ...@@ -354,20 +304,6 @@ function saveSubsection() {
metadata[$(el).data("metadata-name")] = el.value; 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 // 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 // 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 // so make sure we're passing back the correct format
...@@ -382,6 +318,7 @@ function saveSubsection() { ...@@ -382,6 +318,7 @@ function saveSubsection() {
data: JSON.stringify({ 'id': id, 'metadata': metadata}), data: JSON.stringify({ 'id': id, 'metadata': metadata}),
success: function () { success: function () {
$spinner.delay(500).fadeOut(150); $spinner.delay(500).fadeOut(150);
$changedInput = null;
}, },
error: function () { error: function () {
showToastMessage('There has been an error while saving your changes.'); showToastMessage('There has been an error while saving your changes.');
......
// 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({ ...@@ -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 // 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. // A bit funny in that the video key validation is asynchronous; so, it won't stop the validation.
var errors = {}; 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) { 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."; errors.end_date = "The course end date cannot be before the course start date.";
} }
......
// <!-- from https://github.com/Gazler/Underscore-Template-Loader/blob/master/index.html --> // <!-- 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) // 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. // so this only loads the lazily loaded ones.
(function() { (function () {
if (typeof window.templateLoader == 'function') return; if (typeof window.templateLoader == 'function') return;
var templateLoader = { var templateLoader = {
templateVersion: "0.0.16", templateVersion: "0.0.15",
templates: {}, templates: {},
loadRemoteTemplate: function(templateName, filename, callback) { // Control whether template caching in local memory occurs. Caching screws up development but may
if (!this.templates[templateName]) { // be a good optimization in production (it works fairly well).
var self = this; cacheTemplates: false,
jQuery.ajax({url : filename, loadRemoteTemplate: function (templateName, filename, callback) {
success : function(data) { if (!this.templates[templateName]) {
self.addTemplate(templateName, data); var self = this;
self.saveLocalTemplates(); jQuery.ajax({url: filename,
callback(data); success: function (data) {
}, self.addTemplate(templateName, data);
error : function(xhdr, textStatus, errorThrown) { self.saveLocalTemplates();
console.log(textStatus); }, callback(data);
dataType : "html" },
}) error: function (xhdr, textStatus, errorThrown) {
} console.log(textStatus);
else { },
callback(this.templates[templateName]); dataType: "html"
} })
}, }
else {
addTemplate: function(templateName, data) { callback(this.templates[templateName]);
// 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; 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
localStorageAvailable: function() { // if it maintains a separate cache of uncompiled ones
try { this.templates[templateName] = data;
// window.cachetemplates is global set in base.js to turn caching on/off },
return window.cachetemplates && 'localStorage' in window && window['localStorage'] !== null;
} catch (e) { localStorageAvailable: function () {
return false; 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); 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) { loadLocalTemplates: function () {
var templates = localStorage.getItem("templates"); if (this.localStorageAvailable()) {
if (templates) { var templateVersion = localStorage.getItem("templateVersion");
templates = JSON.parse(templates); if (templateVersion && templateVersion == this.templateVersion) {
for (var x in templates) { var templates = localStorage.getItem("templates");
if (!this.templates[x]) { if (templates) {
this.addTemplate(x, templates[x]); 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");
} }
}
} }
}
else {
localStorage.removeItem("templates");
localStorage.removeItem("templateVersion");
}
} }
}
}; };
templateLoader.loadLocalTemplates(); templateLoader.loadLocalTemplates();
window.templateLoader = templateLoader; window.templateLoader = templateLoader;
})(); })();
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
});
}
});
\ No newline at end of file
...@@ -101,6 +101,12 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -101,6 +101,12 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
cacheModel.save(fieldName, newVal); 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 // instrument as date and time pickers
......
...@@ -25,11 +25,7 @@ CMS.Views.ValidatingView = Backbone.View.extend({ ...@@ -25,11 +25,7 @@ CMS.Views.ValidatingView = Backbone.View.extend({
for (var field in error) { for (var field in error) {
var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); var ele = this.$el.find('#' + this.fieldToSelectorMap[field]);
this._cacheValidationErrors.push(ele); this._cacheValidationErrors.push(ele);
if ($(ele).is('div')) { this.getInputElements(ele).addClass('error');
// put error on the contained inputs
$(ele).find('input, textarea').addClass('error');
}
else $(ele).addClass('error');
$(ele).parent().append(this.errorTemplate({message : error[field]})); $(ele).parent().append(this.errorTemplate({message : error[field]}));
} }
}, },
...@@ -37,12 +33,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({ ...@@ -37,12 +33,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({
clearValidationErrors : function() { clearValidationErrors : function() {
// error is object w/ fields and error strings // error is object w/ fields and error strings
while (this._cacheValidationErrors.length > 0) { while (this._cacheValidationErrors.length > 0) {
var ele = this._cacheValidationErrors.pop(); var ele = this._cacheValidationErrors.pop();
if ($(ele).is('div')) { this.getInputElements(ele).removeClass('error');
// put error on the contained inputs
$(ele).find('input, textarea').removeClass('error');
}
else $(ele).removeClass('error');
$(ele).nextAll('.message-error').remove(); $(ele).nextAll('.message-error').remove();
} }
}, },
...@@ -65,5 +57,16 @@ CMS.Views.ValidatingView = Backbone.View.extend({ ...@@ -65,5 +57,16 @@ CMS.Views.ValidatingView = Backbone.View.extend({
}, },
inputUnfocus : function(event) { inputUnfocus : function(event) {
$("label[for='" + event.currentTarget.id + "']").removeClass("is-focused"); $("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);
}
} }
}); });
...@@ -214,7 +214,7 @@ h1 { ...@@ -214,7 +214,7 @@ h1 {
color: $gray-l2; color: $gray-l2;
} }
.title, .title-1 { .title-1 {
@include font-size(32); @include font-size(32);
margin: 0; margin: 0;
padding: 0; padding: 0;
...@@ -283,8 +283,8 @@ h1 { ...@@ -283,8 +283,8 @@ h1 {
.title-3 { .title-3 {
@include font-size(16); @include font-size(16);
margin: 0 0 ($baseline/4) 0; margin: 0 0 ($baseline/2) 0;
font-weight: 500; font-weight: 600;
} }
.title-4 { .title-4 {
...@@ -327,7 +327,8 @@ h1 { ...@@ -327,7 +327,8 @@ h1 {
} }
} }
.nav-related { // navigation
.nav-related, .nav-page {
.nav-item { .nav-item {
margin-bottom: ($baseline/4); margin-bottom: ($baseline/4);
...@@ -643,7 +644,7 @@ hr.divide { ...@@ -643,7 +644,7 @@ hr.divide {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
z-index: 99999; z-index: 10000;
padding: 0 10px; padding: 0 10px;
border-radius: 3px; border-radius: 3px;
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.85);
...@@ -691,6 +692,10 @@ hr.divide { ...@@ -691,6 +692,10 @@ hr.divide {
word-wrap: break-word; word-wrap: break-word;
} }
hr.divider {
@extend .sr;
}
// ==================== // ====================
// js dependant // js dependant
......
...@@ -22,6 +22,7 @@ $black-t0: rgba(0,0,0,0.125); ...@@ -22,6 +22,7 @@ $black-t0: rgba(0,0,0,0.125);
$black-t1: rgba(0,0,0,0.25); $black-t1: rgba(0,0,0,0.25);
$black-t2: rgba(0,0,0,0.50); $black-t2: rgba(0,0,0,0.50);
$black-t3: rgba(0,0,0,0.75); $black-t3: rgba(0,0,0,0.75);
$white: rgb(255,255,255); $white: rgb(255,255,255);
$white-t0: rgba(255,255,255,0.125); $white-t0: rgba(255,255,255,0.125);
$white-t1: rgba(255,255,255,0.25); $white-t1: rgba(255,255,255,0.25);
...@@ -34,6 +35,7 @@ $gray-l2: tint($gray,40%); ...@@ -34,6 +35,7 @@ $gray-l2: tint($gray,40%);
$gray-l3: tint($gray,60%); $gray-l3: tint($gray,60%);
$gray-l4: tint($gray,80%); $gray-l4: tint($gray,80%);
$gray-l5: tint($gray,90%); $gray-l5: tint($gray,90%);
$gray-l6: tint($gray,95%);
$gray-d1: shade($gray,20%); $gray-d1: shade($gray,20%);
$gray-d2: shade($gray,40%); $gray-d2: shade($gray,40%);
$gray-d3: shade($gray,60%); $gray-d3: shade($gray,60%);
...@@ -161,4 +163,4 @@ $disabledGreen: rgb(124, 206, 153); ...@@ -161,4 +163,4 @@ $disabledGreen: rgb(124, 206, 153);
$darkGreen: rgb(52, 133, 76); $darkGreen: rgb(52, 133, 76);
$lightBluishGrey: rgb(197, 207, 223); $lightBluishGrey: rgb(197, 207, 223);
$lightBluishGrey2: rgb(213, 220, 228); $lightBluishGrey2: rgb(213, 220, 228);
$error-red: rgb(253, 87, 87); $error-red: rgb(253, 87, 87);
\ No newline at end of file
...@@ -28,6 +28,7 @@ ...@@ -28,6 +28,7 @@
@import 'elements/modal'; @import 'elements/modal';
@import 'elements/alerts'; @import 'elements/alerts';
@import 'elements/jquery-ui-calendar'; @import 'elements/jquery-ui-calendar';
@import 'elements/tender-widget';
// specific views // specific views
@import 'views/account'; @import 'views/account';
...@@ -43,6 +44,7 @@ ...@@ -43,6 +44,7 @@
@import 'views/subsection'; @import 'views/subsection';
@import 'views/unit'; @import 'views/unit';
@import 'views/users'; @import 'views/users';
@import 'views/checklists';
@import 'assets/content-types'; @import 'assets/content-types';
......
...@@ -132,7 +132,7 @@ ...@@ -132,7 +132,7 @@
// specific elements - course nav // specific elements - course nav
.nav-course { .nav-course {
width: 335px; width: 285px;
margin-top: -($baseline/4); margin-top: -($baseline/4);
@include font-size(14); @include font-size(14);
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
font-family: $sans-serif; font-family: $sans-serif;
font-size: 12px; font-size: 12px;
@include box-shadow(0 5px 10px rgba(0, 0, 0, 0.1)); @include box-shadow(0 5px 10px rgba(0, 0, 0, 0.1));
z-index: 100000 !important;
.ui-widget-header { .ui-widget-header {
background: $darkGrey; background: $darkGrey;
......
// 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;
}
\ No newline at end of file
// 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);
}
}
\ No newline at end of file
...@@ -172,89 +172,11 @@ body.course.subsection { ...@@ -172,89 +172,11 @@ body.course.subsection {
font-size: 14px; font-size: 14px;
} }
.unit-subtitle {
display: block;
width: 100%;
}
.sortable-unit-list { .sortable-unit-list {
ol { ol {
@include tree-view; @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 { .subsection-name-input {
......
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%block name="bodyclass">is-signedin course uploads</%block> <%block name="bodyclass">is-signedin course uploads</%block>
<%block name="title">Uploads &amp; Files</%block> <%block name="title">Files &amp; Uploads</%block>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
......
...@@ -45,6 +45,7 @@ ...@@ -45,6 +45,7 @@
<script src="${static.url('js/vendor/jquery.leanModal.min.js')}"></script> <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.tablednd.js')}"></script>
<script src="${static.url('js/vendor/jquery.form.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/htmlmixed.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script>
<script type="text/javascript"> <script type="text/javascript">
...@@ -54,7 +55,7 @@ ...@@ -54,7 +55,7 @@
<%block name="content"></%block> <%block name="content"></%block>
<%include file="widgets/footer.html" /> <%include file="widgets/footer.html" />
<%include file="widgets/tender.html" />
<%block name="jsextra"></%block> <%block name="jsextra"></%block>
</body> </body>
</html> </html>
......
<%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 @@ ...@@ -2,7 +2,7 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<!-- TODO decode course # from context_course into title --> <!-- 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> <%block name="bodyclass">is-signedin course course-info updates</%block>
......
...@@ -31,18 +31,6 @@ ...@@ -31,18 +31,6 @@
</article> </article>
</div> </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"/>:&nbsp;<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="sidebar">
<div class="unit-settings window id-holder" data-id="${subsection.location}"> <div class="unit-settings window id-holder" data-id="${subsection.location}">
<h4 class="header">Subsection Settings</h4> <h4 class="header">Subsection Settings</h4>
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! from django.core.urlresolvers import reverse %> <%! 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="bodyclass">is-signedin course tools export</%block>
<%block name="content"> <%block name="content">
......
...@@ -151,7 +151,7 @@ ...@@ -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> <figcaption class="description">Simple two-level outline to organize your couse. Drag and drop, and see your course at a glance.</figcaption>
</figure> </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">&#x2421;</i> <i class="ss-icon ss-symbolicons-block icon icon-close">&#x2421;</i>
<span class="label">close modal</span> <span class="label">close modal</span>
</a> </a>
...@@ -164,7 +164,7 @@ ...@@ -164,7 +164,7 @@
<figcaption class="description">Quickly create videos, text snippets, inline discussions, and a variety of problem types.</figcaption> <figcaption class="description">Quickly create videos, text snippets, inline discussions, and a variety of problem types.</figcaption>
</figure> </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">&#x2421;</i> <i class="ss-icon ss-symbolicons-block icon icon-close">&#x2421;</i>
<span class="label">close modal</span> <span class="label">close modal</span>
</a> </a>
...@@ -177,7 +177,7 @@ ...@@ -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> <figcaption class="description">Simply set the date of a section or subsection, and Studio will publish it to your students for you.</figcaption>
</figure> </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">&#x2421;</i> <i class="ss-icon ss-symbolicons-block icon icon-close">&#x2421;</i>
<span class="label">close modal</span> <span class="label">close modal</span>
</a> </a>
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! from django.core.urlresolvers import reverse %> <%! 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="bodyclass">is-signedin course tools import</%block>
<%block name="content"> <%block name="content">
......
<%inherit file="base.html" /> <%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="bodyclass">is-signedin index dashboard</%block>
<%block name="header_extras"> <%block name="header_extras">
......
<%inherit file="base.html" /> <%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> <%block name="bodyclass">is-signedin course users settings team</%block>
......
...@@ -200,7 +200,7 @@ ...@@ -200,7 +200,7 @@
</a> </a>
</div> </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>
<div class="item-actions"> <div class="item-actions">
......
<%inherit file="base.html" /> <%inherit file="base.html" />
<%block name="title">Schedule &amp; Details</%block> <%block name="title">Schedule &amp; Details Settings</%block>
<%block name="bodyclass">is-signedin course schedule settings</%block> <%block name="bodyclass">is-signedin course schedule settings</%block>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
......
<%inherit file="base.html" /> <%inherit file="base.html" />
<%block name="title">Grading</%block> <%block name="title">Grading Settings</%block>
<%block name="bodyclass">is-signedin course grading settings</%block> <%block name="bodyclass">is-signedin course grading settings</%block>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
......
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
This unit was originally published on ${published_date}. This unit was originally published on ${published_date}.
% endif % endif
</p> </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>
<div class="main-column"> <div class="main-column">
<article class="unit-body window"> <article class="unit-body window">
......
...@@ -14,15 +14,14 @@ ...@@ -14,15 +14,14 @@
<li class="nav-item nav-peripheral-pp"> <li class="nav-item nav-peripheral-pp">
<a href="#">Privacy Policy</a> <a href="#">Privacy Policy</a>
</li> --> </li> -->
<li class="nav-item nav-peripheral-help"> <li class="nav-item nav-peripheral-help">
<a href="http://help.edge.edx.org/" rel="external">edX Studio Help</a> <a href="http://help.edge.edx.org/" rel="external">edX Studio Help</a>
</li> </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(): % 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 % endif
</ol> </ol>
</nav> </nav>
......
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<div class="wrapper-header wrapper"> <div class="wrapper-header wrapper" id="view-top">
<header class="primary" role="banner"> <header class="primary" role="banner">
<div class="wrapper wrapper-left "> <div class="wrapper wrapper-left ">
...@@ -56,6 +56,7 @@ ...@@ -56,6 +56,7 @@
<div class="wrapper wrapper-nav-sub"> <div class="wrapper wrapper-nav-sub">
<div class="nav-sub"> <div class="nav-sub">
<ul> <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-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> <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> </ul>
......
% 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
\ No newline at end of file
...@@ -42,36 +42,52 @@ urlpatterns = ('', ...@@ -42,36 +42,52 @@ urlpatterns = ('',
'contentstore.views.remove_user', name='remove_user'), 'contentstore.views.remove_user', name='remove_user'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<name>[^/]+)/remove_user$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<name>[^/]+)/remove_user$',
'contentstore.views.remove_user', 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>[^/]+)/info/(?P<name>[^/]+)$',
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course_info/updates/(?P<provided_id>.*)$', 'contentstore.views.course_info_updates', name='course_info_json'), 'contentstore.views.course_info', name='course_info'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'), url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course_info/updates/(?P<provided_id>.*)$',
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$', 'contentstore.views.course_config_graders_page', name='course_settings'), 'contentstore.views.course_info_updates', name='course_info_json'),
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-details/(?P<name>[^/]+)$',
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)/(?P<grader_index>.*)$', 'contentstore.views.course_grader_updates', name='course_settings'), '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. # 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. # 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'), name='static_pages'),
url(r'^edit_static/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_static', name='edit_static'), url(r'^edit_static/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'), 'contentstore.views.edit_static', name='edit_static'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$', 'contentstore.views.asset_index', name='asset_index'), 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 # 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 # 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'^not_found$', 'contentstore.views.not_found', name='not_found'),
url(r'^server_error$', 'contentstore.views.server_error', name='server_error'), 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 # temporary landing page for edge
url(r'^edge$', 'contentstore.views.edge', name='edge'), url(r'^edge$', 'contentstore.views.edge', name='edge'),
...@@ -83,6 +99,9 @@ urlpatterns = ('', ...@@ -83,6 +99,9 @@ urlpatterns = ('',
# User creation and updating views # User creation and updating views
urlpatterns += ( urlpatterns += (
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'^howitworks$', 'contentstore.views.howitworks', name='howitworks'),
url(r'^signup$', 'contentstore.views.signup', name='signup'), url(r'^signup$', 'contentstore.views.signup', name='signup'),
......
...@@ -40,7 +40,6 @@ class CmsNamespace(Namespace): ...@@ -40,7 +40,6 @@ class CmsNamespace(Namespace):
""" """
Namespace with fields common to all blocks in Studio 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_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) 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) 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 ...@@ -5,6 +5,7 @@ from django.http import HttpResponse, Http404, HttpResponseNotModified
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent, XASSET_LOCATION_TAG 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 cache_toolbox.core import get_cached_content, set_cached_content
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
...@@ -13,7 +14,14 @@ class StaticContentServer(object): ...@@ -13,7 +14,14 @@ class StaticContentServer(object):
def process_request(self, request): def process_request(self, request):
# look to see if the request is prefixed with 'c4x' tag # look to see if the request is prefixed with 'c4x' tag
if request.path.startswith('/' + XASSET_LOCATION_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 # first look in our cache so we don't have to round-trip to the DB
content = get_cached_content(loc) content = get_cached_content(loc)
if content is None: if content is None:
......
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
\ No newline at end of file
...@@ -75,10 +75,15 @@ class UserProfile(models.Model): ...@@ -75,10 +75,15 @@ class UserProfile(models.Model):
GENDER_CHOICES = (('m', 'Male'), ('f', 'Female'), ('o', 'Other')) GENDER_CHOICES = (('m', 'Male'), ('f', 'Female'), ('o', 'Other'))
gender = models.CharField(blank=True, null=True, max_length=6, db_index=True, gender = models.CharField(blank=True, null=True, max_length=6, db_index=True,
choices=GENDER_CHOICES) 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"), ('m', "Master's or professional degree"),
('b', "Bachelor's degree"), ('b', "Bachelor's degree"),
('a', "Associate's degree"),
('hs', "Secondary/high school"), ('hs', "Secondary/high school"),
('jhs', "Junior secondary/junior high/middle school"), ('jhs', "Junior secondary/junior high/middle school"),
('el', "Elementary/primary school"), ('el', "Elementary/primary school"),
......
...@@ -311,7 +311,7 @@ def change_enrollment(request): ...@@ -311,7 +311,7 @@ def change_enrollment(request):
course = course_from_id(course_id) course = course_from_id(course_id)
except ItemNotFoundError: except ItemNotFoundError:
log.warning("User {0} tried to enroll in non-existent course {1}" 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.'} return {'success': False, 'error': 'The course requested does not exist.'}
if not has_access(user, course, 'enroll'): if not has_access(user, course, 'enroll'):
...@@ -325,7 +325,12 @@ def change_enrollment(request): ...@@ -325,7 +325,12 @@ def change_enrollment(request):
"course:{0}".format(course_num), "course:{0}".format(course_num),
"run:{0}".format(run)]) "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} return {'success': True}
elif action == "unenroll": elif action == "unenroll":
...@@ -369,14 +374,14 @@ def login_user(request, error=""): ...@@ -369,14 +374,14 @@ def login_user(request, error=""):
try: try:
user = User.objects.get(email=email) user = User.objects.get(email=email)
except User.DoesNotExist: 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, return HttpResponse(json.dumps({'success': False,
'value': 'Email or password is incorrect.'})) # TODO: User error message 'value': 'Email or password is incorrect.'})) # TODO: User error message
username = user.username username = user.username
user = authenticate(username=username, password=password) user = authenticate(username=username, password=password)
if user is None: 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, return HttpResponse(json.dumps({'success': False,
'value': 'Email or password is incorrect.'})) 'value': 'Email or password is incorrect.'}))
...@@ -392,7 +397,7 @@ def login_user(request, error=""): ...@@ -392,7 +397,7 @@ def login_user(request, error=""):
log.critical("Login failed - Could not create session. Is memcached running?") log.critical("Login failed - Could not create session. Is memcached running?")
log.exception(e) 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) try_change_enrollment(request)
...@@ -400,7 +405,7 @@ def login_user(request, error=""): ...@@ -400,7 +405,7 @@ def login_user(request, error=""):
return HttpResponse(json.dumps({'success': True})) 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) reactivation_email_for_user(user)
not_activated_msg = "This account has not been activated. We have " + \ not_activated_msg = "This account has not been activated. We have " + \
......
#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()
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from .factories import * from .course_helpers import *
from .ui_helpers import *
from lettuce.django import django_url from lettuce.django import django_url
from django.conf import settings from nose.tools import assert_equals, assert_in
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 time
import re
import os.path
from selenium.common.exceptions import WebDriverException
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
...@@ -22,7 +14,7 @@ logger = getLogger(__name__) ...@@ -22,7 +14,7 @@ logger = getLogger(__name__)
@step(u'I wait (?:for )?"(\d+)" seconds?$') @step(u'I wait (?:for )?"(\d+)" seconds?$')
def wait(step, seconds): def wait(step, seconds):
time.sleep(float(seconds)) world.wait(seconds)
@step('I reload the page$') @step('I reload the page$')
...@@ -30,44 +22,49 @@ def reload_the_page(step): ...@@ -30,44 +22,49 @@ def reload_the_page(step):
world.browser.reload() 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$') @step('I (?:visit|access|open) the homepage$')
def i_visit_the_homepage(step): def i_visit_the_homepage(step):
world.browser.visit(django_url('/')) world.visit('/')
assert world.browser.is_element_present_by_css('header.global', 10) assert world.is_css_present('header.global')
@step(u'I (?:visit|access|open) the dashboard$') @step(u'I (?:visit|access|open) the dashboard$')
def i_visit_the_dashboard(step): def i_visit_the_dashboard(step):
world.browser.visit(django_url('/dashboard')) world.visit('/dashboard')
assert world.browser.is_element_present_by_css('section.container.dashboard', 5) assert world.is_css_present('section.container.dashboard')
@step('I should be on the dashboard page$') @step('I should be on the dashboard page$')
def i_should_be_on_the_dashboard(step): 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' assert world.browser.title == 'Dashboard'
@step(u'I (?:visit|access|open) the courses page$') @step(u'I (?:visit|access|open) the courses page$')
def i_am_on_the_courses_page(step): def i_am_on_the_courses_page(step):
world.browser.visit(django_url('/courses')) world.visit('/courses')
assert world.browser.is_element_present_by_css('section.courses') assert world.is_css_present('section.courses')
@step(u'I press the "([^"]*)" button$') @step(u'I press the "([^"]*)" button$')
def and_i_press_the_button(step, value): def and_i_press_the_button(step, value):
button_css = 'input[value="%s"]' % 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 "([^"]*)"$') @step(u'I click the link with the text "([^"]*)"$')
def click_the_link_with_the_text_group1(step, linktext): 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 "([^"]*)"$') @step('I should see that the path is "([^"]*)"$')
def i_should_see_that_the_path_is(step, path): 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 "([^"]*)"$') @step(u'the page title should be "([^"]*)"$')
...@@ -80,10 +77,15 @@ def the_page_title_should_contain(step, title): ...@@ -80,10 +77,15 @@ def the_page_title_should_contain(step, title):
assert(title in world.browser.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$') @step('I am a logged in user$')
def i_am_logged_in_user(step): def i_am_logged_in_user(step):
create_user('robot') world.create_user('robot')
log_in('robot', 'test') world.log_in('robot', 'test')
@step('I am not logged in$') @step('I am not logged in$')
...@@ -93,151 +95,46 @@ def i_am_not_logged_in(step): ...@@ -93,151 +95,46 @@ def i_am_not_logged_in(step):
@step('I am staff for course "([^"]*)"$') @step('I am staff for course "([^"]*)"$')
def i_am_staff_for_course_by_id(step, course_id): 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$') @step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$')
def i_log_in(step): def click_the_link_called(step, text):
log_in('robot', 'test') world.click_link(text)
@step(u'I am an edX user$') @step(r'should see that the url is "([^"]*)"$')
def i_am_an_edx_user(step): def should_have_the_url(step, url):
create_user('robot') assert_equals(world.browser.url, url)
#### helper functions @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
@world.absorb
def scroll_to_bottom():
# Maximize the browser
world.browser.execute_script("window.scrollTo(0, screen.height);")
@world.absorb @step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page')
def create_user(uname): def should_see_in_the_page(step, text):
assert_in(text, world.css_text('body'))
# 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') @step('I am logged in$')
portal_user.set_password('test') def i_am_logged_in(step):
portal_user.save() world.create_user('robot')
world.log_in('robot', 'test')
world.browser.visit(django_url('/'))
registration = world.RegistrationFactory(user=portal_user) @step('I am not logged in$')
registration.register(portal_user) def i_am_not_logged_in(step):
registration.activate() world.browser.cookies.delete()
user_profile = world.UserProfileFactory(user=portal_user)
@step(u'I am an edX user$')
def i_am_an_edx_user(step):
world.create_user('robot')
@world.absorb
def log_in(username, password):
'''
Log the user in programatically
'''
# Authenticate the user @step(u'User "([^"]*)" is an edX user$')
user = authenticate(username=username, password=password) def registered_edx_user(step, uname):
assert(user is not None and user.is_active) world.create_user(uname)
# 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()
#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
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. ...@@ -16,7 +16,6 @@ This is used by capa_module.
from __future__ import division from __future__ import division
from datetime import datetime from datetime import datetime
import json
import logging import logging
import math import math
import numpy import numpy
...@@ -32,9 +31,9 @@ from xml.sax.saxutils import unescape ...@@ -32,9 +31,9 @@ from xml.sax.saxutils import unescape
from copy import deepcopy from copy import deepcopy
import chem import chem
import chem.miller
import chem.chemcalc import chem.chemcalc
import chem.chemtools import chem.chemtools
import chem.miller
import verifiers import verifiers
import verifiers.draganddrop import verifiers.draganddrop
...@@ -97,8 +96,13 @@ class LoncapaProblem(object): ...@@ -97,8 +96,13 @@ class LoncapaProblem(object):
- problem_text (string): xml defining the problem - problem_text (string): xml defining the problem
- id (string): identifier for this problem; often a filename (no spaces) - 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, - system (ModuleSystem): ModuleSystem instance which provides OS,
rendering, and user context rendering, and user context
...@@ -110,21 +114,23 @@ class LoncapaProblem(object): ...@@ -110,21 +114,23 @@ class LoncapaProblem(object):
self.system = system self.system = system
if self.system is None: if self.system is None:
raise Exception() raise Exception()
self.seed = seed
state = state if state else {}
if state:
if 'seed' in state: # Set seed according to the following priority:
self.seed = state['seed'] # 1. Contained in problem's state
if 'student_answers' in state: # 2. Passed into capa_problem via constructor
self.student_answers = state['student_answers'] # 3. Assign from the OS's random number generator
if 'correct_map' in state: self.seed = state.get('seed', seed)
self.correct_map.set_dict(state['correct_map']) if self.seed is None:
if 'done' in state:
self.done = state['done']
# TODO: Does this deplete the Linux entropy pool? Is this fast enough?
if not self.seed:
self.seed = struct.unpack('i', os.urandom(4))[0] 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> # Convert startouttext and endouttext to proper <text></text>
problem_text = re.sub("startouttext\s*/", "text", problem_text) problem_text = re.sub("startouttext\s*/", "text", problem_text)
...@@ -188,6 +194,7 @@ class LoncapaProblem(object): ...@@ -188,6 +194,7 @@ class LoncapaProblem(object):
return {'seed': self.seed, return {'seed': self.seed,
'student_answers': self.student_answers, 'student_answers': self.student_answers,
'correct_map': self.correct_map.get_dict(), 'correct_map': self.correct_map.get_dict(),
'input_state': self.input_state,
'done': self.done} 'done': self.done}
def get_max_score(self): def get_max_score(self):
...@@ -237,6 +244,20 @@ class LoncapaProblem(object): ...@@ -237,6 +244,20 @@ class LoncapaProblem(object):
self.correct_map.set_dict(cmap.get_dict()) self.correct_map.set_dict(cmap.get_dict())
return cmap 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): def is_queued(self):
''' '''
Returns True if any part of the problem has been submitted to an external queue Returns True if any part of the problem has been submitted to an external queue
...@@ -351,7 +372,7 @@ class LoncapaProblem(object): ...@@ -351,7 +372,7 @@ class LoncapaProblem(object):
dispatch = get['dispatch'] dispatch = get['dispatch']
return self.inputs[input_id].handle_ajax(dispatch, get) return self.inputs[input_id].handle_ajax(dispatch, get)
else: 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 {} return {}
...@@ -527,11 +548,15 @@ class LoncapaProblem(object): ...@@ -527,11 +548,15 @@ class LoncapaProblem(object):
value = "" value = ""
if self.student_answers and problemid in self.student_answers: if self.student_answers and problemid in self.student_answers:
value = self.student_answers[problemid] value = self.student_answers[problemid]
if input_id not in self.input_state:
self.input_state[input_id] = {}
# do the rendering # do the rendering
state = {'value': value, state = {'value': value,
'status': status, 'status': status,
'id': input_id, 'id': input_id,
'input_state': self.input_state[input_id],
'feedback': {'message': msg, 'feedback': {'message': msg,
'hint': hint, 'hint': hint,
'hintmode': hintmode, }} 'hintmode': hintmode, }}
......
...@@ -80,16 +80,17 @@ class CorrectMap(object): ...@@ -80,16 +80,17 @@ class CorrectMap(object):
Special migration case: Special migration case:
If correct_map is a one-level dict, then convert it to the new dict of dicts format. 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
# empty current dict self.__init__()
self.__init__()
# create new dict entries # create new dict entries
if correct_map and not isinstance(correct_map.values()[0], dict):
# special migration
for k in correct_map: for k in correct_map:
self.set(k, correct_map[k]) self.set(k, correctness=correct_map[k])
else: else:
self.__init__()
for k in correct_map: for k in correct_map:
self.set(k, **correct_map[k]) self.set(k, **correct_map[k])
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
% for choice_id, choice_description in choices: % for choice_id, choice_description in choices:
<label for="input_${id}_${choice_id}" <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': if status == 'correct':
correctness = 'correct' correctness = 'correct'
...@@ -30,10 +30,12 @@ ...@@ -30,10 +30,12 @@
class="choicegroup_${correctness}" class="choicegroup_${correctness}"
% endif % endif
% endif % endif
> >
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}" <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" checked="true"
% elif input_type != 'radio' and choice_id in value:
checked="true"
% endif % endif
/> ${choice_description} </label> /> ${choice_description} </label>
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
% endif % endif
<div id="genex_container"></div> <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="genex_problem_number" id="genex_problem_number" value ="${genex_problem_number}"></input>
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/> <input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
......
<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>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment