Merge branch 'master' of github.com:MITx/mitx into fix/cdodge/export-draft-modules

Conflicts:
	cms/djangoapps/contentstore/views.py
	common/lib/xmodule/xmodule/modulestore/draft.py
	common/lib/xmodule/xmodule/modulestore/xml_importer.py
	common/lib/xmodule/xmodule/xml_module.py
This commit is contained in:
Chris Dodge
2013-04-02 09:09:45 -04:00
832 changed files with 62264 additions and 20240 deletions

5
.gitignore vendored
View File

@@ -27,4 +27,7 @@ lms/lib/comment_client/python
nosetests.xml
cover_html/
.idea/
chromedriver.log
.redcar/
chromedriver.log
/nbproject
ghostdriver.log

View File

@@ -12,7 +12,7 @@ profile=no
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS
ignore=CVS, migrations
# Pickle collected data for later comparisons.
persistent=yes
@@ -33,7 +33,16 @@ load-plugins=
# can either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once).
disable=E1102,W0142
disable=
# W0141: Used builtin function 'map'
# W0142: Used * or ** magic
# R0201: Method could be a function
# R0901: Too many ancestors
# R0902: Too many instance attributes
# R0903: Too few public methods (1/2)
# R0904: Too many public methods
# R0913: Too many arguments
W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0913
[REPORTS]
@@ -43,7 +52,7 @@ disable=E1102,W0142
output-format=text
# Include message's id in output
include-ids=no
include-ids=yes
# Put messages in a separate file for each module / package specified on the
# command line instead of printing them on stdout. Reports (if any) will be
@@ -97,7 +106,7 @@ bad-functions=map,filter,apply,input
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
# Regular expression which should only match correct module level names
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$
# Regular expression which should only match correct class names
class-rgx=[A-Z_][a-zA-Z0-9]+$
@@ -106,7 +115,7 @@ class-rgx=[A-Z_][a-zA-Z0-9]+$
function-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct method names
method-rgx=[a-z_][a-z0-9_]{2,30}$
method-rgx=([a-z_][a-z0-9_]{2,60}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*)$
# Regular expression which should only match correct instance attribute names
attr-rgx=[a-z_][a-z0-9_]{2,30}$
@@ -129,7 +138,7 @@ bad-names=foo,bar,baz,toto,tutu,tata
# Regular expression which should only match functions or classes name which do
# not require a docstring
no-docstring-rgx=__.*__
no-docstring-rgx=(__.*__|test_.*)
[MISCELLANEOUS]

View File

@@ -1 +1 @@
1.8.7-p371
1.9.3-p374

View File

@@ -1,4 +1,4 @@
source :rubygems
source 'https://rubygems.org'
gem 'rake', '~> 10.0.3'
gem 'sass', '3.1.15'
gem 'bourbon', '~> 1.3.6'

View File

@@ -2,7 +2,7 @@
[run]
data_file = reports/cms/.coverage
source = cms,common/djangoapps
omit = cms/envs/*, cms/manage.py
omit = cms/envs/*, cms/manage.py, common/djangoapps/terrain/*, common/djangoapps/*/migrations/*
[report]
ignore_errors = True

View File

@@ -1,3 +1 @@
from xmodule.templates import update_templates
update_templates()

View File

@@ -5,9 +5,11 @@ from lxml import html
import re
from django.http import HttpResponseBadRequest
import logging
import django.utils
## TODO store as array of { date, content } and override course_info_module.definition_from_xml
## This should be in a class which inherits from XmlDescriptor
# # TODO store as array of { date, content } and override course_info_module.definition_from_xml
# # This should be in a class which inherits from XmlDescriptor
log = logging.getLogger(__name__)
def get_course_updates(location):
@@ -26,9 +28,11 @@ def get_course_updates(location):
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = html.fromstring(course_updates.definition['data'])
course_html_parsed = html.fromstring(course_updates.data)
except:
course_html_parsed = html.fromstring("<ol></ol>")
log.error("Cannot parse: " + course_updates.data)
escaped = django.utils.html.escape(course_updates.data)
course_html_parsed = html.fromstring("<ol><li>" + escaped + "</li></ol>")
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
course_upd_collection = []
@@ -60,13 +64,15 @@ def update_course_updates(location, update, passed_id=None):
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
return HttpResponseBadRequest
return HttpResponseBadRequest()
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = html.fromstring(course_updates.definition['data'])
course_html_parsed = html.fromstring(course_updates.data)
except:
course_html_parsed = html.fromstring("<ol></ol>")
log.error("Cannot parse: " + course_updates.data)
escaped = django.utils.html.escape(course_updates.data)
course_html_parsed = html.fromstring("<ol><li>" + escaped + "</li></ol>")
# No try/catch b/c failure generates an error back to client
new_html_parsed = html.fromstring('<li><h2>' + update['date'] + '</h2>' + update['content'] + '</li>')
@@ -85,12 +91,18 @@ def update_course_updates(location, update, passed_id=None):
passed_id = course_updates.location.url() + "/" + str(idx)
# update db record
course_updates.definition['data'] = html.tostring(course_html_parsed)
modulestore('direct').update_item(location, course_updates.definition['data'])
course_updates.data = html.tostring(course_html_parsed)
modulestore('direct').update_item(location, course_updates.data)
if (len(new_html_parsed) == 1):
content = new_html_parsed[0].tail
else:
content = "\n".join([html.tostring(ele)
for ele in new_html_parsed[1:]])
return {"id": passed_id,
"date": update['date'],
"content": update['content']}
"content": content}
def delete_course_update(location, update, passed_id):
@@ -99,19 +111,21 @@ def delete_course_update(location, update, passed_id):
Returns the resulting course_updates b/c their ids change.
"""
if not passed_id:
return HttpResponseBadRequest
return HttpResponseBadRequest()
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
return HttpResponseBadRequest
return HttpResponseBadRequest()
# TODO use delete_blank_text parser throughout and cache as a static var in a class
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = html.fromstring(course_updates.definition['data'])
course_html_parsed = html.fromstring(course_updates.data)
except:
course_html_parsed = html.fromstring("<ol></ol>")
log.error("Cannot parse: " + course_updates.data)
escaped = django.utils.html.escape(course_updates.data)
course_html_parsed = html.fromstring("<ol><li>" + escaped + "</li></ol>")
if course_html_parsed.tag == 'ol':
# ??? Should this use the id in the json or in the url or does it matter?
@@ -122,9 +136,9 @@ def delete_course_update(location, update, passed_id):
course_html_parsed.remove(element_to_delete)
# update db record
course_updates.definition['data'] = html.tostring(course_html_parsed)
course_updates.data = html.tostring(course_html_parsed)
store = modulestore('direct')
store.update_item(location, course_updates.definition['data'])
store.update_item(location, course_updates.data)
return get_course_updates(location)
@@ -133,7 +147,6 @@ def get_idx(passed_id):
"""
From the url w/ idx appended, get the idx.
"""
# TODO compile this regex into a class static and reuse for each call
idx_matcher = re.search(r'.*/(\d+)$', passed_id)
idx_matcher = re.search(r'.*?/?(\d+)$', passed_id)
if idx_matcher:
return int(idx_matcher.group(1))

View File

@@ -0,0 +1,42 @@
Feature: Advanced (manual) course policy
In order to specify course policy settings for which no custom user interface exists
I want to be able to manually enter JSON key /value pairs
Scenario: A course author sees default advanced settings
Given I have opened a new course in Studio
When I select the Advanced Settings
Then I see default advanced settings
Scenario: Add new entries, and they appear alphabetically after save
Given I am on the Advanced Course Settings page in Studio
Then the settings are alphabetized
Scenario: Test cancel editing key value
Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key
And I press the "Cancel" notification button
Then the policy key value is unchanged
And I reload the page
Then the policy key value is unchanged
Scenario: Test editing key value
Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key
And I press the "Save" notification button
Then the policy key value is changed
And I reload the page
Then the policy key value is changed
Scenario: Test how multi-line input appears
Given I am on the Advanced Course Settings page in Studio
When I create a JSON object as a value
Then it is displayed as formatted
And I reload the page
Then it is displayed as formatted
Scenario: Test automatic quoting of non-JSON values
Given I am on the Advanced Course Settings page in Studio
When I create a non-JSON value not in quotes
Then it is displayed as a string
And I reload the page
Then it is displayed as a string

View File

@@ -0,0 +1,132 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from common import *
import time
from terrain.steps import reload_the_page
from nose.tools import assert_true, assert_false, assert_equal
"""
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
"""
from selenium.webdriver.common.keys import Keys
KEY_CSS = '.key input.policy-key'
VALUE_CSS = 'textarea.json'
DISPLAY_NAME_KEY = "display_name"
DISPLAY_NAME_VALUE = '"Robot Super Course"'
############### ACTIONS ####################
@step('I select the Advanced Settings$')
def i_select_advanced_settings(step):
expand_icon_css = 'li.nav-course-settings i.icon-expand'
if world.browser.is_element_present_by_css(expand_icon_css):
world.css_click(expand_icon_css)
link_css = 'li.nav-course-settings-advanced a'
world.css_click(link_css)
@step('I am on the Advanced Course Settings page in Studio$')
def i_am_on_advanced_course_settings(step):
step.given('I have opened a new course in Studio')
step.given('I select the Advanced Settings')
@step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name):
css = 'a.%s-button' % name.lower()
world.css_click_at(css)
@step(u'I edit the value of a policy key$')
def edit_the_value_of_a_policy_key(step):
"""
It is hard to figure out how to get into the CodeMirror
area, so cheat and do it from the policy key field :)
"""
e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X')
@step('I create a JSON object as a value$')
def create_JSON_object(step):
change_display_name_value(step, '{"key": "value", "key_2": "value_2"}')
@step('I create a non-JSON value not in quotes$')
def create_value_not_in_quotes(step):
change_display_name_value(step, 'quote me')
############### RESULTS ####################
@step('I see default advanced settings$')
def i_see_default_advanced_settings(step):
# Test only a few of the existing properties (there are around 34 of them)
assert_policy_entries(
["advanced_modules", DISPLAY_NAME_KEY, "show_calculator"], ["[]", DISPLAY_NAME_VALUE, "false"])
@step('the settings are alphabetized$')
def they_are_alphabetized(step):
key_elements = world.css_find(KEY_CSS)
all_keys = []
for key in key_elements:
all_keys.append(key.value)
assert_equal(sorted(all_keys), all_keys, "policy keys were not sorted")
@step('it is displayed as formatted$')
def it_is_formatted(step):
assert_policy_entries([DISPLAY_NAME_KEY], ['{\n "key": "value",\n "key_2": "value_2"\n}'])
@step('it is displayed as a string')
def it_is_formatted(step):
assert_policy_entries([DISPLAY_NAME_KEY], ['"quote me"'])
@step(u'the policy key value is unchanged$')
def the_policy_key_value_is_unchanged(step):
assert_equal(get_display_name_value(), DISPLAY_NAME_VALUE)
@step(u'the policy key value is changed$')
def the_policy_key_value_is_changed(step):
assert_equal(get_display_name_value(), '"Robot Super Course X"')
############# HELPERS ###############
def assert_policy_entries(expected_keys, expected_values):
for counter in range(len(expected_keys)):
index = get_index_of(expected_keys[counter])
assert_false(index == -1, "Could not find key: " + expected_keys[counter])
assert_equal(expected_values[counter], world.css_find(VALUE_CSS)[index].value, "value is incorrect")
def get_index_of(expected_key):
for counter in range(len(world.css_find(KEY_CSS))):
# Sometimes get stale reference if I hold on to the array of elements
key = world.css_find(KEY_CSS)[counter].value
if key == expected_key:
return counter
return -1
def get_display_name_value():
index = get_index_of(DISPLAY_NAME_KEY)
return world.css_find(VALUE_CSS)[index].value
def change_display_name_value(step, new_value):
e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
display_name = get_display_name_value()
for count in range(len(display_name)):
e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE)
# Must delete "" before typing the JSON value
e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value)
press_the_notification_button(step, "Save")

View File

@@ -0,0 +1,24 @@
Feature: Course checklists
Scenario: A course author sees checklists defined by edX
Given I have opened a new course in Studio
When I select Checklists from the Tools menu
Then I see the four default edX checklists
Scenario: A course author can mark tasks as complete
Given I have opened Checklists
Then I can check and uncheck tasks in a checklist
And They are correctly selected after I reload the page
Scenario: A task can link to a location within Studio
Given I have opened Checklists
When I select a link to the course outline
Then I am brought to the course outline page
And I press the browser back button
Then I am brought back to the course outline in the correct state
Scenario: A task can link to a location outside Studio
Given I have opened Checklists
When I select a link to help page
Then I am brought to the help page in a new window

View File

@@ -0,0 +1,123 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_true, assert_equal
from terrain.steps import reload_the_page
from selenium.common.exceptions import StaleElementReferenceException
############### ACTIONS ####################
@step('I select Checklists from the Tools menu$')
def i_select_checklists(step):
expand_icon_css = 'li.nav-course-tools i.icon-expand'
if world.browser.is_element_present_by_css(expand_icon_css):
world.css_click(expand_icon_css)
link_css = 'li.nav-course-tools-checklists a'
world.css_click(link_css)
@step('I have opened Checklists$')
def i_have_opened_checklists(step):
step.given('I have opened a new course in Studio')
step.given('I select Checklists from the Tools menu')
@step('I see the four default edX checklists$')
def i_see_default_checklists(step):
checklists = world.css_find('.checklist-title')
assert_equal(4, len(checklists))
assert_true(checklists[0].text.endswith('Getting Started With Studio'))
assert_true(checklists[1].text.endswith('Draft a Rough Course Outline'))
assert_true(checklists[2].text.endswith("Explore edX\'s Support Tools"))
assert_true(checklists[3].text.endswith('Draft Your Course About Page'))
@step('I can check and uncheck tasks in a checklist$')
def i_can_check_and_uncheck_tasks(step):
# Use the 2nd checklist as a reference
verifyChecklist2Status(0, 7, 0)
toggleTask(1, 0)
verifyChecklist2Status(1, 7, 14)
toggleTask(1, 3)
verifyChecklist2Status(2, 7, 29)
toggleTask(1, 6)
verifyChecklist2Status(3, 7, 43)
toggleTask(1, 3)
verifyChecklist2Status(2, 7, 29)
@step('They are correctly selected after I reload the page$')
def tasks_correctly_selected_after_reload(step):
reload_the_page(step)
verifyChecklist2Status(2, 7, 29)
# verify that task 7 is still selected by toggling its checkbox state and making sure that it deselects
toggleTask(1, 6)
verifyChecklist2Status(1, 7, 14)
@step('I select a link to the course outline$')
def i_select_a_link_to_the_course_outline(step):
clickActionLink(1, 0, 'Edit Course Outline')
@step('I am brought to the course outline page$')
def i_am_brought_to_course_outline(step):
assert_equal('Course Outline', world.css_find('.outline .title-1')[0].text)
assert_equal(1, len(world.browser.windows))
@step('I am brought back to the course outline in the correct state$')
def i_am_brought_back_to_course_outline(step):
step.given('I see the four default edX checklists')
# In a previous step, we selected (1, 0) in order to click the 'Edit Course Outline' link.
# Make sure the task is still showing as selected (there was a caching bug with the collection).
verifyChecklist2Status(1, 7, 14)
@step('I select a link to help page$')
def i_select_a_link_to_the_help_page(step):
clickActionLink(2, 0, 'Visit Studio Help')
@step('I am brought to the help page in a new window$')
def i_am_brought_to_help_page_in_new_window(step):
step.given('I see the four default edX checklists')
windows = world.browser.windows
assert_equal(2, len(windows))
world.browser.switch_to_window(windows[1])
assert_equal('http://help.edge.edx.org/', world.browser.url)
############### HELPER METHODS ####################
def verifyChecklist2Status(completed, total, percentage):
def verify_count(driver):
try:
statusCount = world.css_find('#course-checklist1 .status-count').first
return statusCount.text == str(completed)
except StaleElementReferenceException:
return False
world.wait_for(verify_count)
assert_equal(str(total), world.css_find('#course-checklist1 .status-amount').first.text)
# Would like to check the CSS width, but not sure how to do that.
assert_equal(str(percentage), world.css_find('#course-checklist1 .viz-checklist-status-value .int').first.text)
def toggleTask(checklist, task):
world.css_click('#course-checklist' + str(checklist) +'-task' + str(task))
def clickActionLink(checklist, task, actionText):
# toggle checklist item to make sure that the link button is showing
toggleTask(checklist, task)
action_link = world.css_find('#course-checklist' + str(checklist) + ' a')[task]
# text will be empty initially, wait for it to populate
def verify_action_link_text(driver):
return action_link.text == actionText
world.wait_for(verify_action_link_text)
action_link.click()

View File

@@ -1,26 +1,27 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from factories import *
from django.core.management import call_command
from lettuce.django import django_url
from django.conf import settings
from django.core.management import call_command
from nose.tools import assert_true
from nose.tools import assert_equal
import xmodule.modulestore.django
from xmodule.modulestore.django import _MODULESTORES, modulestore
from xmodule.templates import update_templates
from auth.authz import get_user_by_email
from logging import getLogger
logger = getLogger(__name__)
########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$')
def i_visit_the_studio_homepage(step):
# To make this go to port 8001, put
# LETTUCE_SERVER_PORT = 8001
# in your settings.py file.
world.browser.visit(django_url('/'))
assert world.browser.is_element_present_by_css('body.no-header', 10)
world.visit('/')
signin_css = 'a.action-signin'
assert world.is_css_present(signin_css)
@step('I am logged into Studio$')
@@ -41,17 +42,23 @@ def i_press_the_category_delete_icon(step, category):
css = 'a.delete-button.delete-subsection-button span.delete-icon'
else:
assert False, 'Invalid category: %s' % category
css_click(css)
world.css_click(css)
@step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step):
world.clear_courses()
log_into_studio()
create_a_course()
####### HELPER FUNCTIONS ##############
def create_studio_user(
uname='robot',
email='robot+studio@edx.org',
password='test',
is_staff=False):
studio_user = UserFactory.build(
studio_user = world.UserFactory.build(
username=uname,
email=email,
password=password,
@@ -59,50 +66,20 @@ def create_studio_user(
studio_user.set_password(password)
studio_user.save()
registration = RegistrationFactory(user=studio_user)
registration = world.RegistrationFactory(user=studio_user)
registration.register(studio_user)
registration.activate()
user_profile = 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()"
xmodule.modulestore.django._MODULESTORES = {}
xmodule.modulestore.django.modulestore().collection.drop()
xmodule.templates.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):
world.browser.find_by_css(css).first.click()
def css_fill(css, value):
world.browser.find_by_css(css).first.fill(value)
def clear_courses():
flush_xmodule_store()
user_profile = world.UserProfileFactory(user=studio_user)
def fill_in_course_info(
name='Robot Super Course',
org='MITx',
num='101'):
css_fill('.new-course-name', name)
css_fill('.new-course-org', org)
css_fill('.new-course-number', num)
world.css_fill('.new-course-name', name)
world.css_fill('.new-course-org', org)
world.css_fill('.new-course-number', num)
def log_into_studio(
@@ -110,39 +87,56 @@ def log_into_studio(
email='robot+studio@edx.org',
password='test',
is_staff=False):
create_studio_user(uname=uname, email=email, is_staff=is_staff)
world.browser.cookies.delete()
world.browser.visit(django_url('/'))
world.browser.is_element_present_by_css('body.no-header', 10)
world.visit('/')
signin_css = 'a.action-signin'
world.is_css_present(signin_css)
world.css_click(signin_css)
login_form = world.browser.find_by_css('form#login_form')
login_form.find_by_name('email').fill(email)
login_form.find_by_name('password').fill(password)
login_form.find_by_name('submit').click()
assert_true(world.browser.is_element_present_by_css('.new-course-button', 5))
assert_true(world.is_css_present('.new-course-button'))
def create_a_course():
css_click('a.new-course-button')
fill_in_course_info()
css_click('input.new-course-save')
assert_true(world.browser.is_element_present_by_css('a#courseware-tab', 5))
c = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
# Add the user to the instructor group of the course
# so they will have the permissions to see it in studio
g = world.GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course')
u = get_user_by_email('robot+studio@edx.org')
u.groups.add(g)
u.save()
world.browser.reload()
course_link_css = 'span.class-name'
world.css_click(course_link_css)
course_title_css = 'span.course-title'
assert_true(world.is_css_present(course_title_css))
def add_section(name='My Section'):
link_css = 'a.new-courseware-section-button'
css_click(link_css)
name_css = '.new-section-name'
save_css = '.new-section-name-save'
css_fill(name_css, name)
css_click(save_css)
world.css_click(link_css)
name_css = 'input.new-section-name'
save_css = 'input.new-section-name-save'
world.css_fill(name_css, name)
world.css_click(save_css)
span_css = 'span.section-name-span'
assert_true(world.is_css_present(span_css))
def add_subsection(name='Subsection One'):
css = 'a.new-subsection-item'
css_click(css)
world.css_click(css)
name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save'
css_fill(name_css, name)
css_click(save_css)
world.css_fill(name_css, name)
world.css_click(save_css)

View File

@@ -0,0 +1,25 @@
Feature: Course Settings
As a course author, I want to be able to configure my course settings.
Scenario: User can set course dates
Given I have opened a new course in Studio
When I select Schedule and Details
And I set course dates
Then I see the set dates on refresh
Scenario: User can clear previously set course dates (except start date)
Given I have set course dates
And I clear all the dates except start
Then I see cleared dates on refresh
Scenario: User cannot clear the course start date
Given I have set course dates
And I clear the course start date
Then I receive a warning about course start date
And The previously set start date is shown on refresh
Scenario: User can correct the course start date warning
Given I have tried to clear the course start
And I have entered a new course start date
Then The warning about course start date goes away
And My new course start date is shown on refresh

View File

@@ -0,0 +1,165 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from terrain.steps import reload_the_page
from selenium.webdriver.common.keys import Keys
import time
from nose.tools import assert_true, assert_false, assert_equal
COURSE_START_DATE_CSS = "#course-start-date"
COURSE_END_DATE_CSS = "#course-end-date"
ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date"
ENROLLMENT_END_DATE_CSS = "#course-enrollment-end-date"
COURSE_START_TIME_CSS = "#course-start-time"
COURSE_END_TIME_CSS = "#course-end-time"
ENROLLMENT_START_TIME_CSS = "#course-enrollment-start-time"
ENROLLMENT_END_TIME_CSS = "#course-enrollment-end-time"
DUMMY_TIME = "3:30pm"
DEFAULT_TIME = "12:00am"
############### ACTIONS ####################
@step('I select Schedule and Details$')
def test_i_select_schedule_and_details(step):
expand_icon_css = 'li.nav-course-settings i.icon-expand'
if world.browser.is_element_present_by_css(expand_icon_css):
world.css_click(expand_icon_css)
link_css = 'li.nav-course-settings-schedule a'
world.css_click(link_css)
@step('I have set course dates$')
def test_i_have_set_course_dates(step):
step.given('I have opened a new course in Studio')
step.given('I select Schedule and Details')
step.given('And I set course dates')
@step('And I set course dates$')
def test_and_i_set_course_dates(step):
set_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
set_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
set_date_or_time(ENROLLMENT_START_DATE_CSS, '12/1/2013')
set_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
set_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
pause()
@step('Then I see the set dates on refresh$')
def test_then_i_see_the_set_dates_on_refresh(step):
reload_the_page(step)
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013')
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
# Unset times get set to 12 AM once the corresponding date has been set.
verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME)
verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME)
verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
@step('And I clear all the dates except start$')
def test_and_i_clear_all_the_dates_except_start(step):
set_date_or_time(COURSE_END_DATE_CSS, '')
set_date_or_time(ENROLLMENT_START_DATE_CSS, '')
set_date_or_time(ENROLLMENT_END_DATE_CSS, '')
pause()
@step('Then I see cleared dates on refresh$')
def test_then_i_see_cleared_dates_on_refresh(step):
reload_the_page(step)
verify_date_or_time(COURSE_END_DATE_CSS, '')
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '')
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '')
verify_date_or_time(COURSE_END_TIME_CSS, '')
verify_date_or_time(ENROLLMENT_START_TIME_CSS, '')
verify_date_or_time(ENROLLMENT_END_TIME_CSS, '')
# Verify course start date (required) and time still there
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
@step('I clear the course start date$')
def test_i_clear_the_course_start_date(step):
set_date_or_time(COURSE_START_DATE_CSS, '')
@step('I receive a warning about course start date$')
def test_i_receive_a_warning_about_course_start_date(step):
assert_true(world.css_has_text('.message-error', 'The course must have an assigned start date.'))
assert_true('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class'))
assert_true('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
@step('The previously set start date is shown on refresh$')
def test_the_previously_set_start_date_is_shown_on_refresh(step):
reload_the_page(step)
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
@step('Given I have tried to clear the course start$')
def test_i_have_tried_to_clear_the_course_start(step):
step.given("I have set course dates")
step.given("I clear the course start date")
step.given("I receive a warning about course start date")
@step('I have entered a new course start date$')
def test_i_have_entered_a_new_course_start_date(step):
set_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
pause()
@step('The warning about course start date goes away$')
def test_the_warning_about_course_start_date_goes_away(step):
assert_equal(0, len(world.css_find('.message-error')))
assert_false('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class'))
assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
@step('My new course start date is shown on refresh$')
def test_my_new_course_start_date_is_shown_on_refresh(step):
reload_the_page(step)
verify_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
# Time should have stayed from before attempt to clear date.
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
############### HELPER METHODS ####################
def set_date_or_time(css, date_or_time):
"""
Sets date or time field.
"""
world.css_fill(css, date_or_time)
e = world.css_find(css).first
# hit Enter to apply the changes
e._element.send_keys(Keys.ENTER)
def verify_date_or_time(css, date_or_time):
"""
Verifies date or time field.
"""
assert_equal(date_or_time, world.css_find(css).first.value)
def pause():
"""
Must sleep briefly to allow last time save to finish,
else refresh of browser will fail.
"""
time.sleep(float(1))

View File

@@ -10,4 +10,4 @@ Feature: Create Course
And I fill in the new course information
And I press the "Save" button
Then the Courseware page has loaded in Studio
And I see a link for adding a new section
And I see a link for adding a new section

View File

@@ -1,3 +1,6 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from common import *
@@ -6,12 +9,12 @@ from common import *
@step('There are no courses$')
def no_courses(step):
clear_courses()
world.clear_courses()
@step('I click the New Course button$')
def i_click_new_course(step):
css_click('.new-course-button')
world.css_click('.new-course-button')
@step('I fill in the new course information$')
@@ -27,36 +30,36 @@ def i_create_a_course(step):
@step('I click the course link in My Courses$')
def i_click_the_course_link_in_my_courses(step):
course_css = 'span.class-name'
css_click(course_css)
world.css_click(course_css)
############ ASSERTIONS ###################
@step('the Courseware page has loaded in Studio$')
def courseware_page_has_loaded_in_studio(step):
courseware_css = 'a#courseware-tab'
assert world.browser.is_element_present_by_css(courseware_css)
course_title_css = 'span.course-title'
assert world.is_css_present(course_title_css)
@step('I see the course listed in My Courses$')
def i_see_the_course_in_my_courses(step):
course_css = 'span.class-name'
assert_css_with_text(course_css, 'Robot Super Course')
assert world.css_has_text(course_css, 'Robot Super Course')
@step('the course is loaded$')
def course_is_loaded(step):
class_css = 'a.class-name'
assert_css_with_text(class_css, 'Robot Super Course')
assert world.css_has_text(course_css, 'Robot Super Cousre')
@step('I am on the "([^"]*)" tab$')
def i_am_on_tab(step, tab_name):
header_css = 'div.inner-wrapper h1'
assert_css_with_text(header_css, tab_name)
assert world.css_has_text(header_css, tab_name)
@step('I see a link for adding a new section$')
def i_see_new_section_link(step):
link_css = 'a.new-courseware-section-button'
assert_css_with_text(link_css, 'New Section')
assert world.css_has_text(link_css, '+ New Section')

View File

@@ -1,34 +0,0 @@
import factory
from student.models import User, UserProfile, Registration
from datetime import datetime
import uuid
class UserProfileFactory(factory.Factory):
FACTORY_FOR = UserProfile
user = None
name = 'Robot Studio'
courseware = 'course.xml'
class RegistrationFactory(factory.Factory):
FACTORY_FOR = Registration
user = None
activation_key = uuid.uuid4().hex
class UserFactory(factory.Factory):
FACTORY_FOR = User
username = 'robot-studio'
email = 'robot+studio@edx.org'
password = 'test'
first_name = 'Robot'
last_name = 'Studio'
is_staff = False
is_active = True
is_superuser = False
last_login = datetime.now()
date_joined = datetime.now()

View File

@@ -11,6 +11,14 @@ Feature: Create Section
And I see a release date for my section
And I see a link to create a new subsection
Scenario: Add a new section (with a quote in the name) to a course (bug #216)
Given I have opened a new course in Studio
When I click the New Section link
And I enter a section name with a quote and click save
Then I see my section name with a quote on the Courseware page
And I click to edit the section name
Then I see the complete section name with a quote in the editor
Scenario: Edit section release date
Given I have opened a new course in Studio
And I have added a new section
@@ -18,9 +26,10 @@ Feature: Create Section
And I save a new section release date
Then the section release date is updated
@skip-phantom
Scenario: Delete section
Given I have opened a new course in Studio
And I have added a new section
When I press the "section" delete icon
And I confirm the alert
Then the section does not exist
Then the section does not exist

View File

@@ -1,28 +1,29 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from common import *
from nose.tools import assert_equal
from selenium.webdriver.common.keys import Keys
import time
############### ACTIONS ####################
@step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step):
clear_courses()
log_into_studio()
create_a_course()
@step('I click the new section link$')
def i_click_new_section_link(step):
link_css = 'a.new-courseware-section-button'
css_click(link_css)
world.css_click(link_css)
@step('I enter the section name and click save$')
def i_save_section_name(step):
name_css = '.new-section-name'
save_css = '.new-section-name-save'
css_fill(name_css, 'My Section')
css_click(save_css)
save_section_name('My Section')
@step('I enter a section name with a quote and click save$')
def i_save_section_name_with_quote(step):
save_section_name('Section with "Quote"')
@step('I have added a new section$')
@@ -33,26 +34,47 @@ def i_have_added_new_section(step):
@step('I click the Edit link for the release date$')
def i_click_the_edit_link_for_the_release_date(step):
button_css = 'div.section-published-date a.edit-button'
css_click(button_css)
world.css_click(button_css)
@step('I save a new section release date$')
def i_save_a_new_section_release_date(step):
date_css = 'input.start-date.date.hasDatepicker'
time_css = 'input.start-time.time.ui-timepicker-input'
css_fill(date_css, '12/25/2013')
# click here to make the calendar go away
css_click(time_css)
css_fill(time_css, '12:00am')
css_click('a.save-button')
world.css_fill(date_css, '12/25/2013')
# hit TAB to get to the time field
e = world.css_find(date_css).first
e._element.send_keys(Keys.TAB)
world.css_fill(time_css, '12:00am')
e = world.css_find(time_css).first
e._element.send_keys(Keys.TAB)
time.sleep(float(1))
world.browser.click_link_by_text('Save')
############ ASSERTIONS ###################
@step('I see my section on the Courseware page$')
def i_see_my_section_on_the_courseware_page(step):
section_css = 'span.section-name-span'
assert_css_with_text(section_css, 'My Section')
see_my_section_on_the_courseware_page('My Section')
@step('I see my section name with a quote on the Courseware page$')
def i_see_my_section_name_with_quote_on_the_courseware_page(step):
see_my_section_on_the_courseware_page('Section with "Quote"')
@step('I click to edit the section name$')
def i_click_to_edit_section_name(step):
world.css_click('span.section-name-span')
@step('I see the complete section name with a quote in the editor$')
def i_see_complete_section_name_with_quote_in_editor(step):
css = '.edit-section-name'
assert world.is_css_present(css)
assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
@step('the section does not exist$')
@@ -66,7 +88,7 @@ def i_see_a_release_date_for_my_section(step):
import re
css = 'span.published-status'
assert world.browser.is_element_present_by_css(css)
assert world.is_css_present(css)
status_text = world.browser.find_by_css(css).text
# e.g. 11/06/2012 at 16:25
@@ -80,17 +102,31 @@ def i_see_a_release_date_for_my_section(step):
@step('I see a link to create a new subsection$')
def i_see_a_link_to_create_a_new_subsection(step):
css = 'a.new-subsection-item'
assert world.browser.is_element_present_by_css(css)
assert world.is_css_present(css)
@step('the section release date picker is not visible$')
def the_section_release_date_picker_not_visible(step):
css = 'div.edit-subsection-publish-settings'
assert False, world.browser.find_by_css(css).visible
assert not world.css_visible(css)
@step('the section release date is updated$')
def the_section_release_date_is_updated(step):
css = 'span.published-status'
status_text = world.browser.find_by_css(css).text
assert status_text == 'Will Release: 12/25/2013 at 12:00am'
status_text = world.css_text(css)
assert_equal(status_text, 'Will Release: 12/25/2013 at 12:00am')
############ HELPER METHODS ###################
def save_section_name(name):
name_css = '.new-section-name'
save_css = '.new-section-name-save'
world.css_fill(name_css, name)
world.css_click(save_css)
def see_my_section_on_the_courseware_page(name):
section_css = 'span.section-name-span'
assert world.css_has_text(section_css, name)

View File

@@ -5,8 +5,8 @@ Feature: Sign in
Scenario: Sign up from the homepage
Given I visit the Studio homepage
When I click the link with the text "Sign up"
When I click the link with the text "Sign Up"
And I fill in the registration form
And I press the "Create My Account" button on the registration form
And I press the Create My Account button on the registration form
Then I should see be on the studio home page
And I should see the message "please click on the activation link in your email."
And I should see the message "please click on the activation link in your email."

View File

@@ -1,4 +1,8 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from common import *
@step('I fill in the registration form$')
@@ -11,10 +15,13 @@ def i_fill_in_the_registration_form(step):
register_form.find_by_name('terms_of_service').check()
@step('I press the "([^"]*)" button on the registration form$')
def i_press_the_button_on_the_registration_form(step, button):
register_form = world.browser.find_by_css('form#register_form')
register_form.find_by_value(button).click()
@step('I press the Create My Account button on the registration form$')
def i_press_the_button_on_the_registration_form(step):
submit_css = 'form#register_form button#submit'
# Workaround for click not working on ubuntu
# for some unknown reason.
e = world.css_find(submit_css)
e.type(' ')
@step('I should see be on the studio home page$')

View File

@@ -1,29 +1,30 @@
Feature: Overview Toggle Section
In order to quickly view the details of a course's section or to scan the inventory of sections
As a course author
I want to toggle the visibility of each section's subsection details in the overview listing
As a course author
I want to toggle the visibility of each section's subsection details in the overview listing
Scenario: The default layout for the overview page is to show sections in expanded view
Given I have a course with multiple sections
When I navigate to the course overview page
Then I see the "Collapse All Sections" link
And all sections are expanded
When I navigate to the course overview page
Then I see the "Collapse All Sections" link
And all sections are expanded
Scenario: Expand/collapse for a course with no sections
Scenario: Expand /collapse for a course with no sections
Given I have a course with no sections
When I navigate to the course overview page
Then I do not see the "Collapse All Sections" link
When I navigate to the course overview page
Then I do not see the "Collapse All Sections" link
Scenario: Collapse link appears after creating first section of a course
Given I have a course with no sections
When I navigate to the course overview page
And I add a section
Then I see the "Collapse All Sections" link
And all sections are expanded
When I navigate to the course overview page
And I add a section
Then I see the "Collapse All Sections" link
And all sections are expanded
@skip-phantom
Scenario: Collapse link is not removed after last section of a course is deleted
Given I have a course with 1 section
And I navigate to the course overview page
And I navigate to the course overview page
When I press the "section" delete icon
And I confirm the alert
Then I see the "Collapse All Sections" link

View File

@@ -1,5 +1,7 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from terrain.factories import *
from common import *
from nose.tools import assert_true, assert_false, assert_equal
@@ -9,16 +11,16 @@ logger = getLogger(__name__)
@step(u'I have a course with no sections$')
def have_a_course(step):
clear_courses()
course = CourseFactory.create()
world.clear_courses()
course = world.CourseFactory.create()
@step(u'I have a course with 1 section$')
def have_a_course_with_1_section(step):
clear_courses()
course = CourseFactory.create()
section = ItemFactory.create(parent_location=course.location)
subsection1 = ItemFactory.create(
world.clear_courses()
course = world.CourseFactory.create()
section = world.ItemFactory.create(parent_location=course.location)
subsection1 = world.ItemFactory.create(
parent_location=section.location,
template='i4x://edx/templates/sequential/Empty',
display_name='Subsection One',)
@@ -26,21 +28,21 @@ def have_a_course_with_1_section(step):
@step(u'I have a course with multiple sections$')
def have_a_course_with_two_sections(step):
clear_courses()
course = CourseFactory.create()
section = ItemFactory.create(parent_location=course.location)
subsection1 = ItemFactory.create(
world.clear_courses()
course = world.CourseFactory.create()
section = world.ItemFactory.create(parent_location=course.location)
subsection1 = world.ItemFactory.create(
parent_location=section.location,
template='i4x://edx/templates/sequential/Empty',
display_name='Subsection One',)
section2 = ItemFactory.create(
section2 = world.ItemFactory.create(
parent_location=course.location,
display_name='Section Two',)
subsection2 = ItemFactory.create(
subsection2 = world.ItemFactory.create(
parent_location=section2.location,
template='i4x://edx/templates/sequential/Empty',
display_name='Subsection Alpha',)
subsection3 = ItemFactory.create(
subsection3 = world.ItemFactory.create(
parent_location=section2.location,
template='i4x://edx/templates/sequential/Empty',
display_name='Subsection Beta',)
@@ -50,7 +52,7 @@ def have_a_course_with_two_sections(step):
def navigate_to_the_course_overview_page(step):
log_into_studio(is_staff=True)
course_locator = '.class-name'
css_click(course_locator)
world.css_click(course_locator)
@step(u'I navigate to the courseware page of a course with multiple sections')
@@ -67,44 +69,44 @@ def i_add_a_section(step):
@step(u'I click the "([^"]*)" link$')
def i_click_the_text_span(step, text):
span_locator = '.toggle-button-sections span'
assert_true(world.browser.is_element_present_by_css(span_locator, 5))
assert_true(world.browser.is_element_present_by_css(span_locator))
# first make sure that the expand/collapse text is the one you expected
assert_equal(world.browser.find_by_css(span_locator).value, text)
css_click(span_locator)
world.css_click(span_locator)
@step(u'I collapse the first section$')
def i_collapse_a_section(step):
collapse_locator = 'section.courseware-section a.collapse'
css_click(collapse_locator)
world.css_click(collapse_locator)
@step(u'I expand the first section$')
def i_expand_a_section(step):
expand_locator = 'section.courseware-section a.expand'
css_click(expand_locator)
world.css_click(expand_locator)
@step(u'I see the "([^"]*)" link$')
def i_see_the_span_with_text(step, text):
span_locator = '.toggle-button-sections span'
assert_true(world.browser.is_element_present_by_css(span_locator, 5))
assert_equal(world.browser.find_by_css(span_locator).value, text)
assert_true(world.browser.find_by_css(span_locator).visible)
assert_true(world.is_css_present(span_locator))
assert_equal(world.css_find(span_locator).value, text)
assert_true(world.css_visible(span_locator))
@step(u'I do not see the "([^"]*)" link$')
def i_do_not_see_the_span_with_text(step, text):
# Note that the span will exist on the page but not be visible
span_locator = '.toggle-button-sections span'
assert_true(world.browser.is_element_present_by_css(span_locator))
assert_false(world.browser.find_by_css(span_locator).visible)
assert_true(world.is_css_present(span_locator))
assert_false(world.css_visible(span_locator))
@step(u'all sections are expanded$')
def all_sections_are_expanded(step):
subsection_locator = 'div.subsection-list'
subsections = world.browser.find_by_css(subsection_locator)
subsections = world.css_find(subsection_locator)
for s in subsections:
assert_true(s.visible)
@@ -112,6 +114,6 @@ def all_sections_are_expanded(step):
@step(u'all sections are collapsed$')
def all_sections_are_expanded(step):
subsection_locator = 'div.subsection-list'
subsections = world.browser.find_by_css(subsection_locator)
subsections = world.css_find(subsection_locator)
for s in subsections:
assert_false(s.visible)

View File

@@ -9,6 +9,23 @@ Feature: Create Subsection
And I enter the subsection name and click save
Then I see my subsection on the Courseware page
Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
Given I have opened a new course section in Studio
When I click the New Subsection link
And I enter a subsection name with a quote and click save
Then I see my subsection name with a quote on the Courseware page
And I click to edit the subsection name
Then I see the complete subsection name with a quote in the editor
Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258)
Given I have opened a new course section in Studio
And I have added a new subsection
And I mark it as Homework
Then I see it marked as Homework
And I reload the page
Then I see it marked as Homework
@skip-phantom
Scenario: Delete a subsection
Given I have opened a new course section in Studio
And I have added a new subsection

View File

@@ -1,12 +1,16 @@
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from common import *
from nose.tools import assert_equal
############### ACTIONS ####################
@step('I have opened a new course section in Studio$')
def i_have_opened_a_new_course_section(step):
clear_courses()
world.clear_courses()
log_into_studio()
create_a_course()
add_section()
@@ -14,34 +18,77 @@ def i_have_opened_a_new_course_section(step):
@step('I click the New Subsection link')
def i_click_the_new_subsection_link(step):
css = 'a.new-subsection-item'
css_click(css)
world.css_click('a.new-subsection-item')
@step('I enter the subsection name and click save$')
def i_save_subsection_name(step):
name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save'
css_fill(name_css, 'Subsection One')
css_click(save_css)
save_subsection_name('Subsection One')
@step('I enter a subsection name with a quote and click save$')
def i_save_subsection_name_with_quote(step):
save_subsection_name('Subsection With "Quote"')
@step('I click to edit the subsection name$')
def i_click_to_edit_subsection_name(step):
world.css_click('span.subsection-name-value')
@step('I see the complete subsection name with a quote in the editor$')
def i_see_complete_subsection_name_with_quote_in_editor(step):
css = '.subsection-display-name-input'
assert world.is_css_present(css)
assert_equal(world.css_find(css).value, 'Subsection With "Quote"')
@step('I have added a new subsection$')
def i_have_added_a_new_subsection(step):
add_subsection()
@step('I 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 ###################
@step('I see my subsection on the Courseware page$')
def i_see_my_subsection_on_the_courseware_page(step):
css = 'span.subsection-name'
assert world.browser.is_element_present_by_css(css)
css = 'span.subsection-name-value'
assert_css_with_text(css, 'Subsection One')
see_subsection_name('Subsection One')
@step('I see my subsection name with a quote on the Courseware page$')
def i_see_my_subsection_name_with_quote_on_the_courseware_page(step):
see_subsection_name('Subsection With "Quote"')
@step('the subsection does not exist$')
def the_subsection_does_not_exist(step):
css = 'span.subsection-name'
assert world.browser.is_element_not_present_by_css(css)
############ HELPER METHODS ###################
def save_subsection_name(name):
name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save'
world.css_fill(name_css, name)
world.css_click(save_css)
def see_subsection_name(name):
css = 'span.subsection-name'
assert world.is_css_present(css)
css = 'span.subsection-name-value'
assert world.css_has_text(css, name)

View File

@@ -7,7 +7,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
from prompt import query_yes_no
from .prompt import query_yes_no
from auth.authz import _delete_course_group
@@ -17,22 +17,29 @@ from auth.authz import _delete_course_group
class Command(BaseCommand):
help = \
'''Delete a MongoDB backed course'''
help = '''Delete a MongoDB backed course'''
def handle(self, *args, **options):
if len(args) != 1:
raise CommandError("delete_course requires one argument: <location>")
if len(args) != 1 and len(args) != 2:
raise CommandError("delete_course requires one or more arguments: <location> |commit|")
loc_str = args[0]
commit = False
if len(args) == 2:
commit = args[1] == 'commit'
if commit:
print 'Actually going to delete the course from DB....'
ms = modulestore('direct')
cs = contentstore()
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
loc = CourseDescriptor.id_to_location(loc_str)
if delete_course(ms, cs, loc) == True:
print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course
_delete_course_group(loc)
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
loc = CourseDescriptor.id_to_location(loc_str)
if delete_course(ms, cs, loc, commit) == True:
print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course
if commit:
_delete_course_group(loc)

View File

@@ -13,7 +13,7 @@ def query_yes_no(question, default="yes"):
"""
valid = {"yes":True, "y":True, "ye":True,
"no":False, "n":False}
if default == None:
if default is None:
prompt = " [y/n] "
elif default == "yes":
prompt = " [Y/n] "

View File

@@ -0,0 +1,9 @@
from xmodule.templates import update_templates
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = \
'''Imports and updates the Studio component templates from the code pack and put in the DB'''
def handle(self, *args, **options):
update_templates()

View File

@@ -15,10 +15,10 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
module = store.clone_item(template_location, location)
data = module.definition['data']
data = module.data
if rewrite_static_links:
data = replace_static_urls(
module.definition['data'],
module.data,
None,
course_namespace=Location([
module.location.tag,
@@ -32,7 +32,8 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
return {
'id': module.location.url(),
'data': data,
'metadata': module.metadata
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
'metadata': module._model_data._kvs._metadata
}
@@ -70,23 +71,23 @@ def set_module_info(store, location, post_data):
# 'apply' the submitted metadata, so we don't end up deleting system metadata
if post_data.get('metadata') is not None:
posted_metadata = post_data['metadata']
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
for metadata_key in posted_metadata.keys():
for metadata_key, value in posted_metadata.items():
# let's strip out any metadata fields from the postback which have been identified as system metadata
# and therefore should not be user-editable, so we should accept them back from the client
if metadata_key in module.system_metadata_fields:
del posted_metadata[metadata_key]
elif posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if metadata_key in module.metadata:
del module.metadata[metadata_key]
if metadata_key in module._model_data:
del module._model_data[metadata_key]
del posted_metadata[metadata_key]
# overlay the new metadata over the modulestore sourced collection to support partial updates
module.metadata.update(posted_metadata)
else:
module._model_data[metadata_key] = value
# commit to datastore
store.update_metadata(location, module.metadata)
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
store.update_metadata(location, module._model_data._kvs._metadata)

View File

@@ -0,0 +1,96 @@
""" Unit tests for checklist methods in views.py. """
from contentstore.utils import get_modulestore, get_url_reverse
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
import json
class ChecklistTestCase(CourseTestCase):
""" Test for checklist get and put methods. """
def setUp(self):
""" Creates the test course. """
super(ChecklistTestCase, self).setUp()
self.course = CourseFactory.create(org='mitX', number='333', display_name='Checklists Course')
def get_persisted_checklists(self):
""" Returns the checklists as persisted in the modulestore. """
modulestore = get_modulestore(self.course.location)
return modulestore.get_item(self.course.location).checklists
def test_get_checklists(self):
""" Tests the get checklists method. """
checklists_url = get_url_reverse('Checklists', self.course)
response = self.client.get(checklists_url)
self.assertContains(response, "Getting Started With Studio")
payload = response.content
# Now delete the checklists from the course and verify they get repopulated (for courses
# created before checklists were introduced).
self.course.checklists = None
modulestore = get_modulestore(self.course.location)
modulestore.update_metadata(self.course.location, own_metadata(self.course))
self.assertEquals(self.get_persisted_checklists(), None)
response = self.client.get(checklists_url)
self.assertEquals(payload, response.content)
def test_update_checklists_no_index(self):
""" No checklist index, should return all of them. """
update_url = reverse('checklists_updates', kwargs={
'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name})
returned_checklists = json.loads(self.client.get(update_url).content)
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
def test_update_checklists_index_ignored_on_get(self):
""" Checklist index ignored on get. """
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
'checklist_index': 1})
returned_checklists = json.loads(self.client.get(update_url).content)
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
def test_update_checklists_post_no_index(self):
""" No checklist index, will error on post. """
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name})
response = self.client.post(update_url)
self.assertContains(response, 'Could not save checklist', status_code=400)
def test_update_checklists_index_out_of_range(self):
""" Checklist index out of range, will error on post. """
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
'checklist_index': 100})
response = self.client.post(update_url)
self.assertContains(response, 'Could not save checklist', status_code=400)
def test_update_checklists_index(self):
""" Check that an update of a particular checklist works. """
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
'checklist_index': 2})
payload = self.course.checklists[2]
self.assertFalse(payload.get('is_checked'))
payload['is_checked'] = True
returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content)
self.assertTrue(returned_checklist.get('is_checked'))
self.assertEqual(self.get_persisted_checklists()[2], returned_checklist)
def test_update_checklists_delete_unsupported(self):
""" Delete operation is not supported. """
update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'name': self.course.location.name,
'checklist_index': 100})
response = self.client.delete(update_url)
self.assertContains(response, 'Unsupported request', status_code=400)

View File

@@ -5,29 +5,28 @@ from django.test.utils import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse
from path import path
from tempfile import mkdtemp
from tempdir import mkdtemp_clean
from datetime import timedelta
import json
from fs.osfs import OSFS
import copy
from mock import Mock
from json import dumps, loads
from json import loads
from student.models import Registration
from django.contrib.auth.models import User
from cms.djangoapps.contentstore.utils import get_modulestore
from contentstore.utils import get_modulestore
from utils import ModuleStoreTestCase, parse_json
from .utils import ModuleStoreTestCase, parse_json
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore import Location
from xmodule.modulestore.store_utilities import clone_course
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore, _MODULESTORES
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.templates import update_templates
from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.templates import update_templates
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
from xmodule.modulestore.inheritance import own_metadata
from xmodule.capa_module import CapaDescriptor
from xmodule.course_module import CourseDescriptor
@@ -38,6 +37,14 @@ TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE)
TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data')
TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data')
class MongoCollectionFindWrapper(object):
def __init__(self, original):
self.original = original
self.counter = 0
def find(self, query, *args, **kwargs):
self.counter = self.counter+1
return self.original(query, *args, **kwargs)
@override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
class ContentStoreToyCourseTest(ModuleStoreTestCase):
@@ -63,7 +70,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.client = Client()
self.client.login(username=uname, password=password)
def check_edit_unit(self, test_course_name):
import_from_xml(modulestore(), 'common/test/data/', [test_course_name])
@@ -79,11 +85,48 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
def test_edit_unit_full(self):
self.check_edit_unit('full')
def _get_draft_counts(self, item):
cnt = 1 if getattr(item, 'is_draft', False) else 0
for child in item.get_children():
cnt = cnt + self._get_draft_counts(child)
return cnt
def test_get_depth_with_drafts(self):
import_from_xml(modulestore(), 'common/test/data/', ['simple'])
course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
'course', '2012_Fall', None]), depth=None)
# make sure no draft items have been returned
num_drafts = self._get_draft_counts(course)
self.assertEqual(num_drafts, 0)
problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
'problem', 'ps01-simple', None]))
# put into draft
modulestore('draft').clone_item(problem.location, problem.location)
# make sure we can query that item and verify that it is a draft
draft_problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
'problem', 'ps01-simple', None]))
self.assertTrue(getattr(draft_problem,'is_draft', False))
#now requery with depth
course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple',
'course', '2012_Fall', None]), depth=None)
# make sure just one draft item have been returned
num_drafts = self._get_draft_counts(course)
self.assertEqual(num_drafts, 1)
def test_static_tab_reordering(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
ms = modulestore('direct')
course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
module_store = modulestore('direct')
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
# reverse the ordering
reverse_tabs = []
@@ -91,9 +134,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
if tab['type'] == 'static_tab':
reverse_tabs.insert(0, 'i4x://edX/full/static_tab/{0}'.format(tab['url_slug']))
resp = self.client.post(reverse('reorder_static_tabs'), json.dumps({'tabs': reverse_tabs}), "application/json")
self.client.post(reverse('reorder_static_tabs'), json.dumps({'tabs': reverse_tabs}), "application/json")
course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
# compare to make sure that the tabs information is in the expected order after the server call
course_tabs = []
@@ -103,29 +146,77 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertEqual(reverse_tabs, course_tabs)
def test_import_polls(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
module_store = modulestore('direct')
found = False
item = None
items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None])
found = len(items) > 0
self.assertTrue(found)
# check that there's actually content in the 'question' field
self.assertGreater(len(items[0].question),0)
def test_xlint_fails(self):
err_cnt = perform_xlint('common/test/data', ['full'])
self.assertGreater(err_cnt, 0)
def test_delete(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
module_store = modulestore('direct')
sequential = module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]))
chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None]))
# make sure the parent no longer points to the child object which was deleted
self.assertTrue(sequential.location.url() in chapter.children)
self.client.post(reverse('delete_item'),
json.dumps({'id': sequential.location.url(), 'delete_children': 'true', 'delete_all_versions': 'true'}),
"application/json")
found = False
try:
module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]))
found = True
except ItemNotFoundError:
pass
self.assertFalse(found)
chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None]))
# make sure the parent no longer points to the child object which was deleted
self.assertFalse(sequential.location.url() in chapter.children)
def test_about_overrides(self):
'''
This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html
while there is a base definition in /about/effort.html
'''
import_from_xml(modulestore(), 'common/test/data/', ['full'])
ms = modulestore('direct')
effort = ms.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None]))
self.assertEqual(effort.definition['data'], '6 hours')
module_store = modulestore('direct')
effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None]))
self.assertEqual(effort.data, '6 hours')
# this one should be in a non-override folder
effort = ms.get_item(Location(['i4x', 'edX', 'full', 'about', 'end_date', None]))
self.assertEqual(effort.definition['data'], 'TBD')
effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'end_date', None]))
self.assertEqual(effort.data, 'TBD')
def test_remove_hide_progress_tab(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
ms = modulestore('direct')
cs = contentstore()
module_store = modulestore('direct')
content_store = contentstore()
source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
course = ms.get_item(source_location)
self.assertNotIn('hide_progress_tab', course.metadata)
course = module_store.get_item(source_location)
self.assertFalse(course.hide_progress_tab)
def test_clone_course(self):
@@ -143,19 +234,19 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
data = parse_json(resp)
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
ms = modulestore('direct')
cs = contentstore()
module_store = modulestore('direct')
content_store = contentstore()
source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course')
clone_course(ms, cs, source_location, dest_location)
clone_course(module_store, content_store, source_location, dest_location)
# now loop through all the units in the course and verify that the clone can render them, which
# means the objects are at least present
items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
self.assertGreater(len(items), 0)
clone_items = ms.get_items(Location(['i4x', 'MITx', '999', 'vertical', None]))
clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'vertical', None]))
self.assertGreater(len(clone_items), 0)
for descriptor in items:
new_loc = descriptor.location._replace(org='MITx', course='999')
@@ -163,17 +254,21 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()}))
self.assertEqual(resp.status_code, 200)
def test_bad_contentstore_request(self):
resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png')
self.assertEqual(resp.status_code, 400)
def test_delete_course(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
ms = modulestore('direct')
cs = contentstore()
module_store = modulestore('direct')
content_store = contentstore()
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
delete_course(ms, cs, location)
delete_course(module_store, content_store, location, commit=True)
items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
self.assertEqual(len(items), 0)
def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''):
@@ -188,54 +283,54 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertTrue(fs.exists(item.location.name + filename_suffix))
def test_export_course(self):
ms = modulestore('direct')
cs = contentstore()
module_store = modulestore('direct')
content_store = contentstore()
import_from_xml(ms, 'common/test/data/', ['full'])
import_from_xml(module_store, 'common/test/data/', ['full'])
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
root_dir = path(mkdtemp())
root_dir = path(mkdtemp_clean())
print 'Exporting to tempdir = {0}'.format(root_dir)
# export out to a tempdir
export_to_xml(ms, cs, location, root_dir, 'test_export')
export_to_xml(module_store, content_store, location, root_dir, 'test_export')
# check for static tabs
self.verify_content_existence(ms, root_dir, location, 'tabs', 'static_tab', '.html')
self.verify_content_existence(module_store, root_dir, location, 'tabs', 'static_tab', '.html')
# check for custom_tags
self.verify_content_existence(ms, root_dir, location, 'info', 'course_info', '.html')
self.verify_content_existence(module_store, root_dir, location, 'info', 'course_info', '.html')
# check for custom_tags
self.verify_content_existence(ms, root_dir, location, 'custom_tags', 'custom_tag_template')
self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template')
# check for graiding_policy.json
fs = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012')
self.assertTrue(fs.exists('grading_policy.json'))
course = ms.get_item(location)
course = module_store.get_item(location)
# compare what's on disk compared to what we have in our course
with fs.open('grading_policy.json','r') as grading_policy:
on_disk = loads(grading_policy.read())
self.assertEqual(on_disk, course.definition['data']['grading_policy'])
with fs.open('grading_policy.json', 'r') as grading_policy:
on_disk = loads(grading_policy.read())
self.assertEqual(on_disk, course.grading_policy)
#check for policy.json
self.assertTrue(fs.exists('policy.json'))
# compare what's on disk to what we have in the course module
with fs.open('policy.json','r') as course_policy:
with fs.open('policy.json', 'r') as course_policy:
on_disk = loads(course_policy.read())
self.assertIn('course/6.002_Spring_2012', on_disk)
self.assertEqual(on_disk['course/6.002_Spring_2012'], course.metadata)
self.assertEqual(on_disk['course/6.002_Spring_2012'], own_metadata(course))
# remove old course
delete_course(ms, cs, location)
delete_course(module_store, content_store, location)
# reimport
import_from_xml(ms, root_dir, ['test_export'])
import_from_xml(module_store, root_dir, ['test_export'])
items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
self.assertGreater(len(items), 0)
for descriptor in items:
print "Checking {0}....".format(descriptor.location.url())
@@ -245,11 +340,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
shutil.rmtree(root_dir)
def test_course_handouts_rewrites(self):
ms = modulestore('direct')
cs = contentstore()
module_store = modulestore('direct')
content_store = contentstore()
# import a test course
import_from_xml(ms, 'common/test/data/', ['full'])
import_from_xml(module_store, 'common/test/data/', ['full'])
handout_location = Location(['i4x', 'edX', 'full', 'course_info', 'handouts'])
@@ -263,6 +358,56 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# note, we know the link it should be because that's what in the 'full' course in the test data
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
def test_prefetch_children(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
module_store = modulestore('direct')
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
wrapper = MongoCollectionFindWrapper(module_store.collection.find)
module_store.collection.find = wrapper.find
course = module_store.get_item(location, depth=2)
# make sure we haven't done too many round trips to DB
# note we say 4 round trips here for 1) the course, 2 & 3) for the chapters and sequentials, and
# 4) because of the RT due to calculating the inherited metadata
self.assertEqual(wrapper.counter, 4)
# make sure we pre-fetched a known sequential which should be at depth=2
self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential',
'Administrivia_and_Circuit_Elements', None]) in course.system.module_data)
# make sure we don't have a specific vertical which should be at depth=3
self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58',
None]) in course.system.module_data)
def test_export_course_with_unknown_metadata(self):
module_store = modulestore('direct')
content_store = contentstore()
import_from_xml(module_store, 'common/test/data/', ['full'])
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
root_dir = path(mkdtemp_clean())
course = module_store.get_item(location)
metadata = own_metadata(course)
# add a bool piece of unknown metadata so we can verify we don't throw an exception
metadata['new_metadata'] = True
module_store.update_metadata(location, metadata)
print 'Exporting to tempdir = {0}'.format(root_dir)
# export out to a tempdir
exported = False
try:
export_to_xml(module_store, content_store, location, root_dir, 'test_export')
exported = True
except Exception:
pass
self.assertTrue(exported)
class ContentStoreTest(ModuleStoreTestCase):
"""
@@ -342,7 +487,7 @@ class ContentStoreTest(ModuleStoreTestCase):
# Create a course so there is something to view
resp = self.client.get(reverse('index'))
self.assertContains(resp,
'<h1>My Courses</h1>',
'<h1 class="title-1">My Courses</h1>',
status_code=200,
html=True)
@@ -378,7 +523,7 @@ class ContentStoreTest(ModuleStoreTestCase):
resp = self.client.get(reverse('course_index', kwargs=data))
self.assertContains(resp,
'<a href="/MITx/999/course/Robot_Super_Course" class="class-name">Robot Super Course</a>',
'<article class="courseware-overview" data-course-id="i4x://MITx/999/course/Robot_Super_Course">',
status_code=200,
html=True)
@@ -405,7 +550,7 @@ class ContentStoreTest(ModuleStoreTestCase):
problem_data = {
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
'template': 'i4x://edx/templates/problem/Empty'
'template': 'i4x://edx/templates/problem/Blank_Common_Problem'
}
resp = self.client.post(reverse('clone_item'), problem_data)
@@ -418,22 +563,77 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
context = problem.get_context()
self.assertIn('markdown', context, "markdown is missing from context")
self.assertIn('markdown', problem.metadata, "markdown is missing from metadata")
self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields")
def test_import_metadata_with_attempts_empty_string(self):
import_from_xml(modulestore(), 'common/test/data/', ['simple'])
module_store = modulestore('direct')
did_load_item = False
try:
module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None]))
did_load_item = True
except ItemNotFoundError:
pass
# make sure we found the item (e.g. it didn't error while loading)
self.assertTrue(did_load_item)
def test_metadata_inheritance(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
module_store = modulestore('direct')
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
verticals = module_store.get_items(['i4x', 'edX', 'full', 'vertical', None, None])
# let's assert on the metadata_inheritance on an existing vertical
for vertical in verticals:
self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key)
self.assertGreater(len(verticals), 0)
new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component')
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
# crate a new module and add it as a child to a vertical
module_store.clone_item(source_template_location, new_component_location)
parent = verticals[0]
module_store.update_children(parent.location, parent.children + [new_component_location.url()])
# flush the cache
module_store.refresh_cached_metadata_inheritance_tree(new_component_location)
new_module = module_store.get_item(new_component_location)
# check for grace period definition which should be defined at the course level
self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod)
self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key)
#
# now let's define an override at the leaf node level
#
new_module.lms.graceperiod = timedelta(1)
module_store.update_metadata(new_module.location, own_metadata(new_module))
# flush the cache and refetch
module_store.refresh_cached_metadata_inheritance_tree(new_component_location)
new_module = module_store.get_item(new_component_location)
self.assertEqual(timedelta(1), new_module.lms.graceperiod)
class TemplateTestCase(ModuleStoreTestCase):
def test_template_cleanup(self):
ms = modulestore('direct')
def test_template_cleanup(self):
module_store = modulestore('direct')
# insert a bogus template in the store
bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus')
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Empty')
ms.clone_item(source_template_location, bogus_template_location)
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
verify_create = ms.get_item(bogus_template_location)
module_store.clone_item(source_template_location, bogus_template_location)
verify_create = module_store.get_item(bogus_template_location)
self.assertIsNotNone(verify_create)
# now run cleanup
@@ -442,10 +642,8 @@ class TemplateTestCase(ModuleStoreTestCase):
# now try to find dangling template, it should not be in DB any longer
asserted = False
try:
verify_create = ms.get_item(bogus_template_location)
verify_create = module_store.get_item(bogus_template_location)
except ItemNotFoundError:
asserted = True
self.assertTrue(asserted)
self.assertTrue(asserted)

View File

@@ -1,46 +1,25 @@
import datetime
import time
import json
import calendar
import copy
from util import converters
from util.converters import jsdate_to_time
from django.contrib.auth.models import User
from django.test.client import Client
from django.core.urlresolvers import reverse
from django.utils.timezone import UTC
import xmodule
from xmodule.modulestore import Location
from cms.djangoapps.models.settings.course_details import (CourseDetails,
from models.settings.course_details import (CourseDetails,
CourseSettingsEncoder)
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.contentstore.utils import get_modulestore
from models.settings.course_grading import CourseGradingModel
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
# YYYY-MM-DDThh:mm:ss.s+/-HH:MM
class ConvertersTestCase(TestCase):
@staticmethod
def struct_to_datetime(struct_time):
return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, struct_time.tm_mday, struct_time.tm_hour,
struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC())
def compare_dates(self, date1, date2, expected_delta):
dt1 = ConvertersTestCase.struct_to_datetime(date1)
dt2 = ConvertersTestCase.struct_to_datetime(date2)
self.assertEqual(dt1 - dt2, expected_delta, str(date1) + "-" + str(date2) + "!=" + str(expected_delta))
def test_iso_to_struct(self):
self.compare_dates(converters.jsdate_to_time("2013-01-01"), converters.jsdate_to_time("2012-12-31"), datetime.timedelta(days=1))
self.compare_dates(converters.jsdate_to_time("2013-01-01T00"), converters.jsdate_to_time("2012-12-31T23"), datetime.timedelta(hours=1))
self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00"), converters.jsdate_to_time("2012-12-31T23:59"), datetime.timedelta(minutes=1))
self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00:00"), converters.jsdate_to_time("2012-12-31T23:59:59"), datetime.timedelta(seconds=1))
from models.settings.course_metadata import CourseMetadata
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.django import modulestore
from xmodule.fields import Date
class CourseTestCase(ModuleStoreTestCase):
def setUp(self):
@@ -103,7 +82,7 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
def test_update_and_fetch(self):
## NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
# # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
jsondetails = CourseDetails.fetch(self.course_location)
jsondetails.syllabus = "<a href='foo'>bar</a>"
# encode - decode to convert date fields and other data which changes form
@@ -143,10 +122,6 @@ class CourseDetailsViewTest(CourseTestCase):
def test_update_and_fetch(self):
details = CourseDetails.fetch(self.course_location)
resp = self.client.get(reverse('course_settings', kwargs={'org': self.course_location.org, 'course': self.course_location.course,
'name': self.course_location.name}))
self.assertContains(resp, '<li><a href="#" class="is-shown" data-section="details">Course Details</a></li>', status_code=200, html=True)
# resp s/b json from here on
url = reverse('course_settings', kwargs={'org': self.course_location.org, 'course': self.course_location.course,
'name': self.course_location.name, 'section': 'details'})
@@ -173,19 +148,26 @@ class CourseDetailsViewTest(CourseTestCase):
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
@staticmethod
def struct_to_datetime(struct_time):
return datetime.datetime(struct_time.tm_year, struct_time.tm_mon,
struct_time.tm_mday, struct_time.tm_hour,
struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC())
def compare_date_fields(self, details, encoded, context, field):
if details[field] is not None:
date = Date()
if field in encoded and encoded[field] is not None:
encoded_encoded = jsdate_to_time(encoded[field])
dt1 = ConvertersTestCase.struct_to_datetime(encoded_encoded)
encoded_encoded = date.from_json(encoded[field])
dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded)
if isinstance(details[field], datetime.datetime):
dt2 = details[field]
else:
details_encoded = jsdate_to_time(details[field])
dt2 = ConvertersTestCase.struct_to_datetime(details_encoded)
details_encoded = date.from_json(details[field])
dt2 = CourseDetailsViewTest.struct_to_datetime(details_encoded)
expected_delta = datetime.timedelta(0)
expected_delta = datetime.timedelta(0)
self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context)
else:
self.fail(field + " missing from encoded but in details at " + context)
@@ -249,8 +231,9 @@ class CourseGradingTest(CourseTestCase):
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D")
test_grader.grace_period = {'hours' : 4, 'minutes' : 5, 'seconds': 0}
test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0}
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
print test_grader.grace_period, altered_grader.grace_period
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period")
def test_update_grader_from_json(self):
@@ -265,3 +248,64 @@ class CourseGradingTest(CourseTestCase):
test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
class CourseMetadataEditingTest(CourseTestCase):
def setUp(self):
CourseTestCase.setUp(self)
# add in the full class too
import_from_xml(modulestore(), 'common/test/data/', ['full'])
self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])
def test_fetch_initial_fields(self):
test_model = CourseMetadata.fetch(self.course_location)
self.assertIn('display_name', test_model, 'Missing editable metadata field')
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
test_model = CourseMetadata.fetch(self.fullcourse_location)
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
self.assertIn('display_name', test_model, 'full missing editable metadata field')
self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
self.assertIn('showanswer', test_model, 'showanswer field ')
self.assertIn('xqa_key', test_model, 'xqa_key field ')
def test_update_from_json(self):
test_model = CourseMetadata.update_from_json(self.course_location,
{ "advertised_start" : "start A",
"testcenter_info" : { "c" : "test" },
"days_early_for_beta" : 2})
self.update_check(test_model)
# try fresh fetch to ensure persistence
test_model = CourseMetadata.fetch(self.course_location)
self.update_check(test_model)
# now change some of the existing metadata
test_model = CourseMetadata.update_from_json(self.course_location,
{ "advertised_start" : "start B",
"display_name" : "jolly roger"})
self.assertIn('display_name', test_model, 'Missing editable metadata field')
self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value")
self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
self.assertEqual(test_model['advertised_start'], 'start B', "advertised_start not expected value")
def update_check(self, test_model):
self.assertIn('display_name', test_model, 'Missing editable metadata field')
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field')
self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value")
self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field')
self.assertDictEqual(test_model['testcenter_info'], { "c" : "test" }, "testcenter_info not expected value")
self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field')
self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value")
def test_delete_key(self):
test_model = CourseMetadata.delete_key(self.fullcourse_location, { 'deleteKeys' : ['doesnt_exist', 'showanswer', 'xqa_key']})
# ensure no harm
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
self.assertIn('display_name', test_model, 'full missing editable metadata field')
self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
# check for deletion effectiveness
self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in')
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')

View File

@@ -1,31 +1,145 @@
from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase
'''unit tests for course_info views and models.'''
from contentstore.tests.test_course_settings import CourseTestCase
from django.core.urlresolvers import reverse
import json
class CourseUpdateTest(CourseTestCase):
'''The do all and end all of unit test cases.'''
def test_course_update(self):
'''Go through each interface and ensure it works.'''
# first get the update to force the creation
url = reverse('course_info', kwargs={'org': self.course_location.org, 'course': self.course_location.course,
'name': self.course_location.name})
url = reverse('course_info',
kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'name': self.course_location.name})
self.client.get(url)
content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0"></iframe>'
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
content = init_content + '</iframe>'
payload = {'content': content,
'date': 'January 8, 2013'}
url = reverse('course_info', kwargs={'org': self.course_location.org, 'course': self.course_location.course,
'provided_id': ''})
url = reverse('course_info_json',
kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json")
payload = json.loads(resp.content)
self.assertHTMLEqual(content, payload['content'], "single iframe")
self.assertHTMLEqual(payload['content'], content)
url = reverse('course_info', kwargs={'org': self.course_location.org, 'course': self.course_location.course,
'provided_id': payload['id']})
content += '<div>div <p>p</p></div>'
first_update_url = reverse('course_info_json',
kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'provided_id': payload['id']})
content += '<div>div <p>p<br/></p></div>'
payload['content'] = content
resp = self.client.post(first_update_url, json.dumps(payload),
"application/json")
self.assertHTMLEqual(content, json.loads(resp.content)['content'],
"iframe w/ div")
# now put in an evil update
content = '<ol/>'
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")
self.assertHTMLEqual(content, json.loads(resp.content)['content'], "iframe w/ div")
payload = json.loads(resp.content)
self.assertHTMLEqual(content, payload['content'], "self closing ol")
url = reverse('course_info_json',
kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'provided_id': ''})
resp = self.client.get(url)
payload = json.loads(resp.content)
self.assertTrue(len(payload) == 2)
# can't test non-json paylod b/c expect_json throws error
# try json w/o required fields
self.assertContains(
self.client.post(url, json.dumps({'garbage': 1}),
"application/json"),
'Failed to save', status_code=400)
# now try to update a non-existent update
url = reverse('course_info_json',
kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'provided_id': '9'})
content = 'blah blah'
payload = {'content': content,
'date': 'January 21, 2013'}
self.assertContains(
self.client.post(url, json.dumps(payload), "application/json"),
'Failed to save', status_code=400)
# update w/ malformed html
content = '<garbage tag No closing brace to force <span>error</span>'
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': ''})
self.assertContains(
self.client.post(url, json.dumps(payload), "application/json"),
'<garbage')
# set to valid html which would break an xml parser
content = "<p><br><br></p>"
payload = {'content': content,
'date': 'January 11, 2013'}
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json")
payload = json.loads(resp.content)
self.assertHTMLEqual(content, json.loads(resp.content)['content'])
# now try to delete a non-existent update
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'provided_id': '19'})
payload = {'content': content,
'date': 'January 21, 2013'}
self.assertContains(self.client.delete(url), "delete", status_code=400)
# now delete a real update
content = 'blah blah'
payload = {'content': content,
'date': 'January 28, 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)
this_id = payload['id']
self.assertHTMLEqual(content, payload['content'], "single iframe")
# first count the entries
url = reverse('course_info_json',
kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'provided_id': ''})
resp = self.client.get(url)
payload = json.loads(resp.content)
before_delete = len(payload)
url = reverse('course_info_json',
kwargs={'org': self.course_location.org,
'course': self.course_location.course,
'provided_id': this_id})
resp = self.client.delete(url)
payload = json.loads(resp.content)
self.assertTrue(len(payload) == before_delete - 1)

View File

@@ -1,19 +1,72 @@
from cms.djangoapps.contentstore import utils
""" Tests for utils. """
from contentstore import utils
import mock
from django.test import TestCase
from xmodule.modulestore.tests.factories import CourseFactory
from .utils import ModuleStoreTestCase
class LMSLinksTestCase(TestCase):
""" Tests for LMS links. """
def about_page_test(self):
""" Get URL for about page. """
location = 'i4x', 'mitX', '101', 'course', 'test'
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
link = utils.get_lms_link_for_about_page(location)
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about")
def ls_link_test(self):
def lms_link_test(self):
""" Tests get_lms_link_for_item. """
location = 'i4x', 'mitX', '101', 'vertical', 'contacting_us'
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
link = utils.get_lms_link_for_item(location, False)
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
link = utils.get_lms_link_for_item(location, True)
self.assertEquals(link, "//preview.localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
self.assertEquals(
link,
"//preview.localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us"
)
class UrlReverseTestCase(ModuleStoreTestCase):
""" Tests for get_url_reverse """
def test_CoursePageNames(self):
""" Test the defined course pages. """
course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course')
self.assertEquals(
'/manage_users/i4x://mitX/666/course/URL_Reverse_Course',
utils.get_url_reverse('ManageUsers', course)
)
self.assertEquals(
'/mitX/666/settings-details/URL_Reverse_Course',
utils.get_url_reverse('SettingsDetails', course)
)
self.assertEquals(
'/mitX/666/settings-grading/URL_Reverse_Course',
utils.get_url_reverse('SettingsGrading', course)
)
self.assertEquals(
'/mitX/666/course/URL_Reverse_Course',
utils.get_url_reverse('CourseOutline', course)
)
self.assertEquals(
'/mitX/666/checklists/URL_Reverse_Course',
utils.get_url_reverse('Checklists', course)
)
def test_unknown_passes_through(self):
""" Test that unknown values pass through. """
course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course')
self.assertEquals(
'foobar',
utils.get_url_reverse('foobar', course)
)
self.assertEquals(
'https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about',
utils.get_url_reverse('https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', course)
)

View File

@@ -4,12 +4,11 @@ from django.test.client import Client
from django.conf import settings
from django.core.urlresolvers import reverse
from path import path
from tempfile import mkdtemp
import json
from fs.osfs import OSFS
import copy
from cms.djangoapps.contentstore.utils import get_modulestore
from contentstore.utils import get_modulestore
from xmodule.modulestore import Location
from xmodule.modulestore.store_utilities import clone_course
@@ -25,7 +24,7 @@ from xmodule.course_module import CourseDescriptor
from xmodule.seq_module import SequenceDescriptor
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from utils import ModuleStoreTestCase, parse_json, user, registration
from .utils import ModuleStoreTestCase, parse_json, user, registration
class ContentStoreTestCase(ModuleStoreTestCase):

View File

@@ -1,6 +1,12 @@
'''
Utilities for contentstore tests
'''
#pylint: disable=W0603
import json
import copy
from time import time
from uuid import uuid4
from django.test import TestCase
from django.conf import settings
@@ -17,37 +23,89 @@ class ModuleStoreTestCase(TestCase):
collection with templates before running the TestCase
and drops it they are finished. """
@staticmethod
def flush_mongo_except_templates():
'''
Delete everything in the module store except templates
'''
modulestore = xmodule.modulestore.django.modulestore()
# This query means: every item in the collection
# that is not a template
query = {"_id.course": {"$ne": "templates"}}
# Remove everything except templates
modulestore.collection.remove(query)
@staticmethod
def load_templates_if_necessary():
'''
Load templates into the modulestore only if they do not already exist.
We need the templates, because they are copied to create
XModules such as sections and problems
'''
modulestore = xmodule.modulestore.django.modulestore()
# Count the number of templates
query = {"_id.course": "templates"}
num_templates = modulestore.collection.find(query).count()
if num_templates < 1:
update_templates()
@classmethod
def setUpClass(cls):
'''
Flush the mongo store and set up templates
'''
# Use a uuid to differentiate
# the mongo collections on jenkins.
cls.orig_modulestore = copy.deepcopy(settings.MODULESTORE)
test_modulestore = cls.orig_modulestore
test_modulestore['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
test_modulestore['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
xmodule.modulestore.django._MODULESTORES = {}
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()
# Use the current seconds since epoch to differentiate
# the mongo collections on jenkins.
sec_since_epoch = '%s' % int(time() * 100)
self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE)
self.test_MODULESTORE = self.orig_MODULESTORE
self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % sec_since_epoch
self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % sec_since_epoch
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 = {}
update_templates()
def _post_teardown(self):
# Make sure you flush out the modulestore.
# Drop the collection at the end of the test,
# otherwise there will be lingering collections leftover
# from executing the tests.
xmodule.modulestore.django._MODULESTORES = {}
xmodule.modulestore.django.modulestore().collection.drop()
settings.MODULESTORE = self.orig_MODULESTORE
'''
Flush everything we created except the templates
'''
# Flush anything that is not a template
ModuleStoreTestCase.flush_mongo_except_templates()
# Call superclass implementation
super(ModuleStoreTestCase, self)._post_teardown()

View File

@@ -1,10 +1,15 @@
import logging
from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from django.core.urlresolvers import reverse
import copy
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
#In order to instantiate an open ended tab automatically, need to have this data
OPEN_ENDED_PANEL = {"name" : "Open Ended Panel", "type" : "open_ended"}
def get_modulestore(location):
"""
@@ -39,10 +44,10 @@ def get_course_location_for_item(location):
# make sure we found exactly one match on this above course search
found_cnt = len(courses)
if found_cnt == 0:
raise BaseException('Could not find course at {0}'.format(course_search_location))
raise Exception('Could not find course at {0}'.format(course_search_location))
if found_cnt > 1:
raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
raise Exception('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
location = courses[0].location
@@ -75,12 +80,20 @@ def get_course_for_item(location):
return courses[0]
def get_lms_link_for_item(location, preview=False):
def get_lms_link_for_item(location, preview=False, course_id=None):
if course_id is None:
course_id = get_course_id(location)
if settings.LMS_BASE is not None:
lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format(
preview='preview.' if preview else '',
lms_base=settings.LMS_BASE,
course_id=get_course_id(location),
if preview:
lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE',
'preview.' + settings.LMS_BASE)
else:
lms_base = settings.LMS_BASE
lms_link = "//{lms_base}/courses/{course_id}/jump_to/{location}".format(
lms_base=lms_base,
course_id=course_id,
location=Location(location)
)
else:
@@ -128,7 +141,7 @@ def compute_unit_state(unit):
'private' content is editabled and not visible in the LMS
"""
if unit.metadata.get('is_draft', False):
if getattr(unit, 'is_draft', False):
try:
modulestore('direct').get_item(unit.location)
return UnitState.draft
@@ -150,3 +163,67 @@ def update_item(location, value):
get_modulestore(location).delete_item(location)
else:
get_modulestore(location).update_item(location, value)
def get_url_reverse(course_page_name, course_module):
"""
Returns the course URL link to the specified location. This value is suitable to use as an href link.
course_page_name should correspond to an attribute in CoursePageNames (for example, 'ManageUsers'
or 'SettingsDetails'), or else it will simply be returned. This method passes back unknown values of
course_page_names so that it can also be used for absolute (known) URLs.
course_module is used to obtain the location, org, course, and name properties for a course, if
course_page_name corresponds to an attribute in CoursePageNames.
"""
url_name = getattr(CoursePageNames, course_page_name, None)
ctx_loc = course_module.location
if CoursePageNames.ManageUsers == url_name:
return reverse(url_name, kwargs={"location": ctx_loc})
elif url_name in [CoursePageNames.SettingsDetails, CoursePageNames.SettingsGrading,
CoursePageNames.CourseOutline, CoursePageNames.Checklists]:
return reverse(url_name, kwargs={'org': ctx_loc.org, 'course': ctx_loc.course, 'name': ctx_loc.name})
else:
return course_page_name
class CoursePageNames:
""" Constants for pages that are recognized by get_url_reverse method. """
ManageUsers = "manage_users"
SettingsDetails = "settings_details"
SettingsGrading = "settings_grading"
CourseOutline = "course_index"
Checklists = "checklists"
def add_open_ended_panel_tab(course):
"""
Used to add the open ended panel tab to a course if it does not exist.
@param course: A course object from the modulestore.
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
"""
#Copy course tabs
course_tabs = copy.copy(course.tabs)
changed = False
#Check to see if open ended panel is defined in the course
if OPEN_ENDED_PANEL not in course_tabs:
#Add panel to the tabs if it is not defined
course_tabs.append(OPEN_ENDED_PANEL)
changed = True
return changed, course_tabs
def remove_open_ended_panel_tab(course):
"""
Used to remove the open ended panel tab from a course if it exists.
@param course: A course object from the modulestore.
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
"""
#Copy course tabs
course_tabs = copy.copy(course.tabs)
changed = False
#Check to see if open ended panel is defined in the course
if OPEN_ENDED_PANEL in course_tabs:
#Add panel to the tabs if it is not defined
course_tabs = [ct for ct in course_tabs if ct!=OPEN_ENDED_PANEL]
changed = True
return changed, course_tabs

View File

@@ -18,7 +18,8 @@ from django.core.files.temp import NamedTemporaryFile
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError
from django.http import HttpResponseNotFound
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.core.context_processors import csrf
@@ -28,16 +29,20 @@ from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.modulestore.inheritance import own_metadata
from xblock.core import Scope
from xblock.runtime import KeyValueStore, DbModel, InvalidScopeError
from xmodule.x_module import ModuleSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
import static_replace
from external_auth.views import ssl_login_shortcut
from xmodule.modulestore.mongo import MongoUsage
from mitxmako.shortcuts import render_to_response, render_to_string
from xmodule.modulestore.django import modulestore
from xmodule_modifiers import replace_static_urls, wrap_xmodule
from xmodule.exceptions import NotFoundError
from xmodule.exceptions import NotFoundError, ProcessingError
from functools import partial
from xmodule.contentstore.django import contentstore
@@ -46,19 +51,21 @@ from xmodule.contentstore.content import StaticContent
from auth.authz import is_user_in_course_group_role, get_users_in_course_group_by_role
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState, get_course_for_item
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, \
get_date_display, UnitState, get_course_for_item, get_url_reverse, add_open_ended_panel_tab, \
remove_open_ended_panel_tab
from xmodule.modulestore.xml_importer import import_from_xml
from contentstore.course_info_model import get_course_updates,\
from contentstore.course_info_model import get_course_updates, \
update_course_updates, delete_course_update
from cache_toolbox.core import del_cached_content
from xmodule.timeparse import stringify_time
from contentstore.module_info_model import get_module_info, set_module_info
from cms.djangoapps.models.settings.course_details import CourseDetails,\
from models.settings.course_details import CourseDetails, \
CourseSettingsEncoder
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.contentstore.utils import get_modulestore
from lxml import etree
from models.settings.course_grading import CourseGradingModel
from contentstore.utils import get_modulestore
from django.shortcuts import redirect
from models.settings.course_metadata import CourseMetadata
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
@@ -67,6 +74,11 @@ log = logging.getLogger(__name__)
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
ADVANCED_COMPONENT_TYPES = ['annotatable'] + OPEN_ENDED_COMPONENT_TYPES
ADVANCED_COMPONENT_CATEGORY = 'advanced'
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
@@ -82,6 +94,13 @@ def signup(request):
return render_to_response('signup.html', {'csrf': csrf_token})
def old_login_redirect(request):
'''
Redirect to the active login url.
'''
return redirect('login', permanent=True)
@ssl_login_shortcut
@ensure_csrf_cookie
def login_page(request):
@@ -95,15 +114,22 @@ def login_page(request):
})
def howitworks(request):
if request.user.is_authenticated():
return index(request)
else:
return render_to_response('howitworks.html', {})
# ==== Views for any logged-in user ==================================
@login_required
@ensure_csrf_cookie
def index(request):
"""
List all courses available to the logged in user
"""
courses = modulestore().get_items(['i4x', None, None, 'course', None])
courses = modulestore('direct').get_items(['i4x', None, None, 'course', None])
# filter out courses that we don't have access too
def course_filter(course):
@@ -116,11 +142,9 @@ def index(request):
return render_to_response('index.html', {
'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'),
'courses': [(course.metadata.get('display_name'),
reverse('course_index', args=[
course.location.org,
course.location.course,
course.location.name]))
'courses': [(course.display_name,
get_url_reverse('CourseOutline', course),
get_lms_link_for_item(course.location, course_id=course.location.course_id))
for course in courses],
'user': request.user,
'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff
@@ -129,6 +153,7 @@ def index(request):
# ==== Views with per-item permissions================================
def has_access(user, location, role=STAFF_ROLE_NAME):
'''
Return True if user allowed to access this piece of data
@@ -155,24 +180,23 @@ def course_index(request, org, course, name):
org, course, name: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
location = get_location_and_verify_access(request, org, course, name)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
lms_link = get_lms_link_for_item(location)
upload_asset_callback_url = reverse('upload_asset', kwargs={
'org': org,
'course': course,
'coursename': name
})
'org': org,
'course': course,
'coursename': name
})
course = modulestore().get_item(location)
course = modulestore().get_item(location, depth=3)
sections = course.get_children()
return render_to_response('overview.html', {
'active_tab': 'courseware',
'context_course': course,
'lms_link': lms_link,
'sections': sections,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
'parent_location': course.location,
@@ -186,19 +210,14 @@ def course_index(request, org, course, name):
@login_required
def edit_subsection(request, location):
# check that we have permissions to edit this item
if not has_access(request.user, location):
course = get_course_for_item(location)
if not has_access(request.user, course.location):
raise PermissionDenied()
item = modulestore().get_item(location)
item = modulestore().get_item(location, depth=1)
# TODO: we need a smarter way to figure out what course an item is in
for course in modulestore().get_courses():
if (course.location.org == item.location.org and
course.location.course == item.location.course):
break
lms_link = get_lms_link_for_item(location)
preview_link = get_lms_link_for_item(location, preview=True)
lms_link = get_lms_link_for_item(location, course_id=course.location.course_id)
preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True)
# make sure that location references a 'sequential', otherwise return BadRequest
if item.location.category != 'sequential':
@@ -215,8 +234,13 @@ def edit_subsection(request, location):
# remove all metadata from the generic dictionary that is presented in a more normalized UI
policy_metadata = dict((key, value) for key, value in item.metadata.iteritems()
if key not in ['display_name', 'start', 'due', 'format'] and key not in item.system_metadata_fields)
policy_metadata = dict(
(field.name, field.read_from(item))
for field
in item.fields
if field.name not in ['display_name', 'start', 'due', 'format'] and
field.scope == Scope.settings
)
can_view_live = False
subsection_units = item.get_children()
@@ -227,18 +251,18 @@ def edit_subsection(request, location):
break
return render_to_response('edit_subsection.html',
{'subsection': item,
'context_course': course,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'lms_link': lms_link,
'preview_link': preview_link,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
'parent_location': course.location,
'parent_item': parent,
'policy_metadata': policy_metadata,
'subsection_units': subsection_units,
'can_view_live': can_view_live
})
{'subsection': item,
'context_course': course,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'lms_link': lms_link,
'preview_link': preview_link,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
'parent_location': course.location,
'parent_item': parent,
'policy_metadata': policy_metadata,
'subsection_units': subsection_units,
'can_view_live': can_view_live
})
@login_required
@@ -250,30 +274,44 @@ def edit_unit(request, location):
id: A Location URL
"""
# check that we have permissions to edit this item
if not has_access(request.user, location):
course = get_course_for_item(location)
if not has_access(request.user, course.location):
raise PermissionDenied()
item = modulestore().get_item(location)
item = modulestore().get_item(location, depth=1)
# TODO: we need a smarter way to figure out what course an item is in
for course in modulestore().get_courses():
if (course.location.org == item.location.org and
course.location.course == item.location.course):
break
lms_link = get_lms_link_for_item(item.location)
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
component_templates = defaultdict(list)
# Check if there are any advanced modules specified in the course policy. These modules
# should be specified as a list of strings, where the strings are the names of the modules
# in ADVANCED_COMPONENT_TYPES that should be enabled for the course.
course_advanced_keys = course.advanced_modules
# Set component types according to course policy file
component_types = list(COMPONENT_TYPES)
if isinstance(course_advanced_keys, list):
course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES]
if len(course_advanced_keys) > 0:
component_types.append(ADVANCED_COMPONENT_CATEGORY)
else:
log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys))
templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
for template in templates:
if template.location.category in COMPONENT_TYPES:
component_templates[template.location.category].append((
template.display_name,
category = template.location.category
if category in course_advanced_keys:
category = ADVANCED_COMPONENT_CATEGORY
if category in component_types:
# This is a hack to create categories for different xmodules
component_templates[category].append((
template.display_name_with_default,
template.location.url(),
'markdown' in template.metadata,
template.location.name == 'Empty'
hasattr(template, 'markdown') and template.markdown is not None,
template.cms.empty,
))
components = [
@@ -302,8 +340,11 @@ def edit_unit(request, location):
break
index = index + 1
preview_lms_link = '//{preview}{lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
preview='preview.',
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE',
'preview.' + settings.LMS_BASE)
preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
preview_lms_base=preview_lms_base,
lms_base=settings.LMS_BASE,
org=course.location.org,
course=course.location.course,
@@ -314,11 +355,6 @@ def edit_unit(request, location):
unit_state = compute_unit_state(item)
try:
published_date = time.strftime('%B %d, %Y', item.metadata.get('published_date'))
except TypeError:
published_date = None
return render_to_response('unit.html', {
'context_course': course,
'active_tab': 'courseware',
@@ -329,11 +365,11 @@ def edit_unit(request, location):
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
'subsection': containing_subsection,
'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.start))) if containing_subsection.start is not None else None,
'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.lms.start))) if containing_subsection.lms.start is not None else None,
'section': containing_section,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'unit_state': unit_state,
'published_date': published_date,
'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None,
})
@@ -365,7 +401,7 @@ def assignment_type_update(request, org, course, category, name):
if request.method == 'GET':
return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)),
mimetype="application/json")
elif request.method == 'POST': # post or put, doesn't matter.
elif request.method == 'POST': # post or put, doesn't matter.
return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)),
mimetype="application/json")
@@ -398,59 +434,28 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
dispatch: The action to execute
"""
instance_state, shared_state = load_preview_state(request, preview_id, location)
descriptor = modulestore().get_item(location)
instance = load_preview_module(request, preview_id, descriptor, instance_state, shared_state)
instance = load_preview_module(request, preview_id, descriptor)
# Let the module handle the AJAX
try:
ajax_return = instance.handle_ajax(dispatch, request.POST)
except NotFoundError:
log.exception("Module indicating to user that request doesn't exist")
raise Http404
except ProcessingError:
log.warning("Module raised an error while processing AJAX request",
exc_info=True)
return HttpResponseBadRequest()
except:
log.exception("error processing ajax call")
raise
save_preview_state(request, preview_id, location, instance.get_instance_state(), instance.get_shared_state())
return HttpResponse(ajax_return)
def load_preview_state(request, preview_id, location):
"""
Load the state of a preview module from the request
preview_id (str): An identifier specifying which preview this module is used for
location: The Location of the module to dispatch to
"""
if 'preview_states' not in request.session:
request.session['preview_states'] = defaultdict(dict)
instance_state = request.session['preview_states'][preview_id, location].get('instance')
shared_state = request.session['preview_states'][preview_id, location].get('shared')
return instance_state, shared_state
def save_preview_state(request, preview_id, location, instance_state, shared_state):
"""
Save the state of a preview module to the request
preview_id (str): An identifier specifying which preview this module is used for
location: The Location of the module to dispatch to
instance_state: The instance state to save
shared_state: The shared state to save
"""
if 'preview_states' not in request.session:
request.session['preview_states'] = defaultdict(dict)
# request.session doesn't notice indirect changes; so, must set its dict w/ every change to get
# it to persist: http://www.djangobook.com/en/2.0/chapter14.html
preview_states = request.session['preview_states']
preview_states[preview_id, location]['instance'] = instance_state
preview_states[preview_id, location]['shared'] = shared_state
request.session['preview_states'] = preview_states # make session mgmt notice the update
def render_from_lms(template_name, dictionary, context=None, namespace='main'):
"""
Render a template using the LMS MAKO_TEMPLATES
@@ -458,6 +463,33 @@ def render_from_lms(template_name, dictionary, context=None, namespace='main'):
return render_to_string(template_name, dictionary, context, namespace="lms." + namespace)
class SessionKeyValueStore(KeyValueStore):
def __init__(self, request, model_data):
self._model_data = model_data
self._session = request.session
def get(self, key):
try:
return self._model_data[key.field_name]
except (KeyError, InvalidScopeError):
return self._session[tuple(key)]
def set(self, key, value):
try:
self._model_data[key.field_name] = value
except (KeyError, InvalidScopeError):
self._session[tuple(key)] = value
def delete(self, key):
try:
del self._model_data[key.field_name]
except (KeyError, InvalidScopeError):
del self._session[tuple(key)]
def has(self, key):
return key in self._model_data or key in self._session
def preview_module_system(request, preview_id, descriptor):
"""
Returns a ModuleSystem for the specified descriptor that is specialized for
@@ -468,6 +500,14 @@ def preview_module_system(request, preview_id, descriptor):
descriptor: An XModuleDescriptor
"""
def preview_model_data(descriptor):
return DbModel(
SessionKeyValueStore(request, descriptor._model_data),
descriptor.module_class,
preview_id,
MongoUsage(preview_id, descriptor.location.url()),
)
return ModuleSystem(
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
@@ -478,6 +518,7 @@ def preview_module_system(request, preview_id, descriptor):
debug=True,
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
user=request.user,
xblock_model_data=preview_model_data,
)
@@ -490,11 +531,11 @@ def get_preview_module(request, preview_id, descriptor):
preview_id (str): An identifier specifying which preview this module is used for
location: A Location
"""
instance_state, shared_state = descriptor.get_sample_state()[0]
return load_preview_module(request, preview_id, descriptor, instance_state, shared_state)
return load_preview_module(request, preview_id, descriptor)
def load_preview_module(request, preview_id, descriptor, instance_state, shared_state):
def load_preview_module(request, preview_id, descriptor):
"""
Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state
@@ -506,12 +547,13 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
"""
system = preview_module_system(request, preview_id, descriptor)
try:
module = descriptor.xmodule_constructor(system)(instance_state, shared_state)
module = descriptor.xmodule(system)
except:
log.debug("Unable to load preview module", exc_info=True)
module = ErrorDescriptor.from_descriptor(
descriptor,
error_msg=exc_info_to_str(sys.exc_info())
).xmodule_constructor(system)(None, None)
).xmodule(system)
# cdodge: Special case
if module.location.category == 'static_tab':
@@ -529,11 +571,9 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
module.get_html = replace_static_urls(
module.get_html,
module.metadata.get('data_dir', module.location.course),
getattr(module, 'data_dir', module.location.course),
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
)
save_preview_state(request, preview_id, descriptor.location.url(),
module.get_instance_state(), module.get_shared_state())
return module
@@ -547,7 +587,7 @@ def get_module_previews(request, descriptor):
"""
preview_html = []
for idx, (instance_state, shared_state) in enumerate(descriptor.get_sample_state()):
module = load_preview_module(request, str(idx), descriptor, instance_state, shared_state)
module = load_preview_module(request, str(idx), descriptor)
preview_html.append(module.get_html())
return preview_html
@@ -594,6 +634,19 @@ def delete_item(request):
if item.location.revision is None and item.location.category == 'vertical' and delete_all_versions:
modulestore('direct').delete_item(item.location)
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
if delete_all_versions:
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
for parent_loc in parent_locs:
parent = modulestore('direct').get_item(parent_loc)
item_url = item_loc.url()
if item_url in parent.children:
children = parent.children
children.remove(item_url)
parent.children = children
modulestore('direct').update_children(parent.location, parent.children)
return HttpResponse()
@@ -631,7 +684,7 @@ def save_item(request):
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
for metadata_key in posted_metadata.keys():
for metadata_key, value in posted_metadata.items():
# let's strip out any metadata fields from the postback which have been identified as system metadata
# and therefore should not be user-editable, so we should accept them back from the client
@@ -639,15 +692,15 @@ def save_item(request):
del posted_metadata[metadata_key]
elif posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if metadata_key in existing_item.metadata:
del existing_item.metadata[metadata_key]
if metadata_key in existing_item._model_data:
del existing_item._model_data[metadata_key]
del posted_metadata[metadata_key]
# overlay the new metadata over the modulestore sourced collection to support partial updates
existing_item.metadata.update(posted_metadata)
else:
existing_item._model_data[metadata_key] = value
# commit to datastore
store.update_metadata(item_location, existing_item.metadata)
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
store.update_metadata(item_location, own_metadata(existing_item))
return HttpResponse()
@@ -714,23 +767,17 @@ def clone_item(request):
new_item = get_modulestore(template).clone_item(template, dest_location)
# TODO: This needs to be deleted when we have proper storage for static content
new_item.metadata['data_dir'] = parent.metadata['data_dir']
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
new_item.metadata['display_name'] = display_name
new_item.display_name = display_name
get_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata)
get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
if new_item.location.category not in DETACHED_CATEGORIES:
get_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))
#@login_required
#@ensure_csrf_cookie
def upload_asset(request, org, course, coursename):
'''
@@ -742,9 +789,7 @@ def upload_asset(request, org, course, coursename):
return HttpResponseBadRequest()
# construct a location from the passed in path
location = ['i4x', org, course, 'course', coursename]
if not has_access(request.user, location):
return HttpResponseForbidden()
location = get_location_and_verify_access(request, org, course, coursename)
# Does the course actually exist?!? Get anything from it to prove its existance
@@ -775,7 +820,7 @@ def upload_asset(request, org, course, coursename):
if thumbnail_content is not None:
content.thumbnail_location = thumbnail_location
#then commit the content
# then commit the content
contentstore().save(content)
del_cached_content(content.location)
@@ -793,11 +838,10 @@ def upload_asset(request, org, course, coursename):
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
return response
'''
This view will return all CMS users who are editors for the specified course
'''
@login_required
@ensure_csrf_cookie
def manage_users(request, location):
@@ -827,12 +871,11 @@ def create_json_response(errmsg=None):
return resp
'''
This POST-back view will add a user - specified by email - to the list of editors for
the specified course
'''
@expect_json
@login_required
@ensure_csrf_cookie
@@ -861,12 +904,11 @@ def add_user(request, location):
return create_json_response()
'''
This POST-back view will remove a user - specified by email - from the list of editors for
the specified course
'''
@expect_json
@login_required
@ensure_csrf_cookie
@@ -899,11 +941,7 @@ def landing(request, org, course, coursename):
@ensure_csrf_cookie
def static_pages(request, org, course, coursename):
location = ['i4x', org, course, 'course', coursename]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
location = get_location_and_verify_access(request, org, course, coursename)
course = modulestore().get_item(location)
@@ -949,7 +987,7 @@ def reorder_static_tabs(request):
for tab in course.tabs:
if tab['type'] == 'static_tab':
reordered_tabs.append({'type': 'static_tab',
'name': tab_items[static_tab_idx].metadata.get('display_name'),
'name': tab_items[static_tab_idx].display_name,
'url_slug': tab_items[static_tab_idx].location.name})
static_tab_idx += 1
else:
@@ -958,7 +996,7 @@ def reorder_static_tabs(request):
# OK, re-assemble the static tabs in the new order
course.tabs = reordered_tabs
modulestore('direct').update_metadata(course.location, course.metadata)
modulestore('direct').update_metadata(course.location, own_metadata(course))
return HttpResponse()
@@ -1015,11 +1053,7 @@ def course_info(request, org, course, name, provided_id=None):
org, course, name: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_item(location)
@@ -1058,21 +1092,25 @@ def course_info_updates(request, org, course, provided_id=None):
if not has_access(request.user, location):
raise PermissionDenied()
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
real_method = get_request_method(request)
if request.method == 'GET':
return HttpResponse(json.dumps(get_course_updates(location)), mimetype="application/json")
elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE
return HttpResponse(json.dumps(delete_course_update(location, request.POST, provided_id)), mimetype="application/json")
return HttpResponse(json.dumps(get_course_updates(location)),
mimetype="application/json")
elif real_method == 'DELETE':
try:
return HttpResponse(json.dumps(delete_course_update(location,
request.POST, provided_id)), mimetype="application/json")
except:
return HttpResponseBadRequest("Failed to delete",
content_type="text/plain")
elif request.method == 'POST':
try:
return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
return HttpResponse(json.dumps(update_course_updates(location,
request.POST, provided_id)), mimetype="application/json")
except:
return HttpResponseBadRequest("Failed to save: malformed html", content_type="text/plain")
return HttpResponseBadRequest("Failed to save",
content_type="text/plain")
@expect_json
@@ -1085,11 +1123,7 @@ def module_info(request, module_location):
if not has_access(request.user, location):
raise PermissionDenied()
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
real_method = get_request_method(request)
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links))
@@ -1114,22 +1148,60 @@ def get_course_settings(request, org, course, name):
org, course, name: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_item(location)
course_details = CourseDetails.fetch(location)
return render_to_response('settings.html', {
'active_tab': 'settings',
'context_course': course_module,
'course_location': location,
'details_url': reverse(course_settings_updates,
kwargs={"org": org,
"course": course,
"name": name,
"section": "details"})
})
@login_required
@ensure_csrf_cookie
def course_config_graders_page(request, org, course, name):
"""
Send models and views as well as html for editing the course settings to the client.
org, course, name: Attributes of the Location for the item to edit
"""
location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_item(location)
course_details = CourseGradingModel.fetch(location)
return render_to_response('settings_graders.html', {
'context_course': course_module,
'course_location' : location,
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
})
@login_required
@ensure_csrf_cookie
def course_config_advanced_page(request, org, course, name):
"""
Send models and views as well as html for editing the advanced course settings to the client.
org, course, name: Attributes of the Location for the item to edit
"""
location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_item(location)
return render_to_response('settings_advanced.html', {
'context_course': course_module,
'course_location' : location,
'advanced_dict' : json.dumps(CourseMetadata.fetch(location)),
})
@expect_json
@login_required
@ensure_csrf_cookie
@@ -1141,11 +1213,7 @@ def course_settings_updates(request, org, course, name, section):
org, course: Attributes of the Location for the item to edit
section: one of details, faculty, grading, problems, discussions
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
get_location_and_verify_access(request, org, course, name)
if section == 'details':
manager = CourseDetails
@@ -1157,7 +1225,7 @@ def course_settings_updates(request, org, course, name, section):
# Cannot just do a get w/o knowing the course name :-(
return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course', name])), cls=CourseSettingsEncoder),
mimetype="application/json")
elif request.method == 'POST': # post or put, doesn't matter.
elif request.method == 'POST': # post or put, doesn't matter.
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
mimetype="application/json")
@@ -1173,30 +1241,168 @@ def course_grader_updates(request, org, course, name, grader_index=None):
org, course: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
location = get_location_and_verify_access(request, org, course, name)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
real_method = get_request_method(request)
if real_method == 'GET':
# Cannot just do a get w/o knowing the course name :-(
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(['i4x', org, course, 'course', name]), grader_index)),
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(location), grader_index)),
mimetype="application/json")
elif real_method == "DELETE":
# ??? Shoudl this return anything? Perhaps success fail?
CourseGradingModel.delete_grader(Location(['i4x', org, course, 'course', name]), grader_index)
# ??? Should this return anything? Perhaps success fail?
CourseGradingModel.delete_grader(Location(location), grader_index)
return HttpResponse()
elif request.method == 'POST': # post or put, doesn't matter.
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)),
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(location), request.POST)),
mimetype="application/json")
# # NB: expect_json failed on ["key", "key2"] and json payload
@login_required
@ensure_csrf_cookie
def course_advanced_updates(request, org, course, name):
"""
restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh,
the payload is either a key or a list of keys to delete.
org, course: Attributes of the Location for the item to edit
"""
location = get_location_and_verify_access(request, org, course, name)
real_method = get_request_method(request)
if real_method == 'GET':
return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json")
elif real_method == 'DELETE':
return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))),
mimetype="application/json")
elif real_method == 'POST' or real_method == 'PUT':
# NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
request_body = json.loads(request.body)
#Whether or not to filter the tabs key out of the settings metadata
filter_tabs = True
#Check to see if the user instantiated any advanced components. This is a hack to add the open ended panel tab
#to a course automatically if the user has indicated that they want to edit the combinedopenended or peergrading
#module, and to remove it if they have removed the open ended elements.
if ADVANCED_COMPONENT_POLICY_KEY in request_body:
#Check to see if the user instantiated any open ended components
found_oe_type = False
#Get the course so that we can scrape current tabs
course_module = modulestore().get_item(location)
for oe_type in OPEN_ENDED_COMPONENT_TYPES:
if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]:
#Add an open ended tab to the course if needed
changed, new_tabs = add_open_ended_panel_tab(course_module)
#If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
if changed:
request_body.update({'tabs': new_tabs})
#Indicate that tabs should not be filtered out of the metadata
filter_tabs = False
#Set this flag to avoid the open ended tab removal code below.
found_oe_type = True
break
#If we did not find an open ended module type in the advanced settings,
# we may need to remove the open ended tab from the course.
if not found_oe_type:
#Remove open ended tab to the course if needed
changed, new_tabs = remove_open_ended_panel_tab(course_module)
if changed:
request_body.update({'tabs': new_tabs})
#Indicate that tabs should not be filtered out of the metadata
filter_tabs = False
response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs))
return HttpResponse(response_json, mimetype="application/json")
@ensure_csrf_cookie
@login_required
def get_checklists(request, org, course, name):
"""
Send models, views, and html for displaying the course checklists.
org, course, name: Attributes of the Location for the item to edit
"""
location = get_location_and_verify_access(request, org, course, name)
modulestore = get_modulestore(location)
course_module = modulestore.get_item(location)
new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
template_module = modulestore.get_item(new_course_template)
# If course was created before checklists were introduced, copy them over from the template.
copied = False
if not course_module.checklists:
course_module.checklists = template_module.checklists
copied = True
checklists, modified = expand_checklist_action_urls(course_module)
if copied or modified:
modulestore.update_metadata(location, own_metadata(course_module))
return render_to_response('checklists.html',
{
'context_course': course_module,
'checklists': checklists
})
@ensure_csrf_cookie
@login_required
def update_checklist(request, org, course, name, checklist_index=None):
"""
restful CRUD operations on course checklists. The payload is a json rep of
the modified checklist. For PUT or POST requests, the index of the
checklist being modified must be included; the returned payload will
be just that one checklist. For GET requests, the returned payload
is a json representation of the list of all checklists.
org, course, name: Attributes of the Location for the item to edit
"""
location = get_location_and_verify_access(request, org, course, name)
modulestore = get_modulestore(location)
course_module = modulestore.get_item(location)
real_method = get_request_method(request)
if real_method == 'POST' or real_method == 'PUT':
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
index = int(checklist_index)
course_module.checklists[index] = json.loads(request.body)
checklists, modified = expand_checklist_action_urls(course_module)
modulestore.update_metadata(location, own_metadata(course_module))
return HttpResponse(json.dumps(checklists[index]), mimetype="application/json")
else:
return HttpResponseBadRequest(
"Could not save checklist state because the checklist index was out of range or unspecified.",
content_type="text/plain")
elif request.method == 'GET':
# In the JavaScript view initialize method, we do a fetch to get all the checklists.
checklists, modified = expand_checklist_action_urls(course_module)
if modified:
modulestore.update_metadata(location, own_metadata(course_module))
return HttpResponse(json.dumps(checklists), mimetype="application/json")
else:
return HttpResponseBadRequest("Unsupported request.", content_type="text/plain")
def expand_checklist_action_urls(course_module):
"""
Gets the checklists out of the course module and expands their action urls
if they have not yet been expanded.
Returns the checklists with modified urls, as well as a boolean
indicating whether or not the checklists were modified.
"""
checklists = course_module.checklists
modified = False
for checklist in checklists:
if not checklist.get('action_urls_expanded', False):
for item in checklist.get('items'):
item['action_url'] = get_url_reverse(item.get('action_url'), course_module)
checklist['action_urls_expanded'] = True
modified = True
return checklists, modified
@login_required
@ensure_csrf_cookie
def asset_index(request, org, course, name):
@@ -1205,18 +1411,13 @@ def asset_index(request, org, course, name):
org, course, name: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
location = get_location_and_verify_access(request, org, course, name)
upload_asset_callback_url = reverse('upload_asset', kwargs={
'org': org,
'course': course,
'coursename': name
})
'org': org,
'course': course,
'coursename': name
})
course_module = modulestore().get_item(location)
@@ -1298,13 +1499,10 @@ def create_new_course(request):
new_course = modulestore('direct').clone_item(template, dest_location)
if display_name is not None:
new_course.metadata['display_name'] = display_name
# we need a 'data_dir' for legacy reasons
new_course.metadata['data_dir'] = uuid4().hex
new_course.display_name = display_name
# set a default start date to now
new_course.metadata['start'] = stringify_time(time.gmtime())
new_course.start = time.gmtime()
initialize_course_tabs(new_course)
@@ -1323,23 +1521,19 @@ def initialize_course_tabs(course):
# This logic is repeated in xmodule/modulestore/tests/factories.py
# so if you change anything here, you need to also change it there.
course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
{"type": "progress", "name": "Progress"}]
modulestore('direct').update_metadata(course.location.url(), course.own_metadata)
modulestore('direct').update_metadata(course.location.url(), own_metadata(course))
@ensure_csrf_cookie
@login_required
def import_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
location = get_location_and_verify_access(request, org, course, name)
if request.method == 'POST':
filename = request.FILES['course-data'].name
@@ -1402,20 +1596,14 @@ def import_course(request, org, course, name):
return render_to_response('import.html', {
'context_course': course_module,
'active_tab': 'import',
'successful_import_redirect_url': reverse('course_index', args=[
course_module.location.org,
course_module.location.course,
course_module.location.name])
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
})
@ensure_csrf_cookie
@login_required
def generate_export_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
location = get_location_and_verify_access(request, org, course, name)
loc = Location(location)
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
@@ -1448,11 +1636,9 @@ def generate_export_course(request, org, course, name):
@login_required
def export_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
location = get_location_and_verify_access(request, org, course, name)
course_module = modulestore().get_item(location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
return render_to_response('export.html', {
'context_course': course_module,
@@ -1467,3 +1653,39 @@ def event(request):
console logs don't get distracted :-)
'''
return HttpResponse(True)
def render_404(request):
return HttpResponseNotFound(render_to_string('404.html', {}))
def render_500(request):
return HttpResponseServerError(render_to_string('500.html', {}))
def get_location_and_verify_access(request, org, course, name):
"""
Create the location tuple verify that the user has permissions
to view the location. Returns the location.
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
return location
def get_request_method(request):
"""
Using HTTP_X_HTTP_METHOD_OVERRIDE, in the request metadata, determine
what type of request came from the client, and return it.
"""
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
return real_method

View File

@@ -1,13 +1,13 @@
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata
import json
from json.encoder import JSONEncoder
import time
from contentstore.utils import get_modulestore
from util.converters import jsdate_to_time, time_to_date
from cms.djangoapps.models.settings import course_grading
from cms.djangoapps.contentstore.utils import update_item
from models.settings import course_grading
from contentstore.utils import update_item
from xmodule.fields import Date
import re
import logging
@@ -43,25 +43,25 @@ class CourseDetails(object):
temploc = course_location._replace(category='about', name='syllabus')
try:
course.syllabus = get_modulestore(temploc).get_item(temploc).definition['data']
course.syllabus = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='overview')
try:
course.overview = get_modulestore(temploc).get_item(temploc).definition['data']
course.overview = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='effort')
try:
course.effort = get_modulestore(temploc).get_item(temploc).definition['data']
course.effort = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='video')
try:
raw_video = get_modulestore(temploc).get_item(temploc).definition['data']
raw_video = get_modulestore(temploc).get_item(temploc).data
course.intro_video = CourseDetails.parse_video_tag(raw_video)
except ItemNotFoundError:
pass
@@ -80,8 +80,14 @@ class CourseDetails(object):
dirty = False
# In the descriptor's setter, the date is converted to JSON using Date's to_json method.
# Calling to_json on something that is already JSON doesn't work. Since reaching directly
# into the model is nasty, convert the JSON Date to a Python date, which is what the
# setter expects as input.
date = Date()
if 'start_date' in jsondict:
converted = jsdate_to_time(jsondict['start_date'])
converted = date.from_json(jsondict['start_date'])
else:
converted = None
if converted != descriptor.start:
@@ -89,7 +95,7 @@ class CourseDetails(object):
descriptor.start = converted
if 'end_date' in jsondict:
converted = jsdate_to_time(jsondict['end_date'])
converted = date.from_json(jsondict['end_date'])
else:
converted = None
@@ -98,7 +104,7 @@ class CourseDetails(object):
descriptor.end = converted
if 'enrollment_start' in jsondict:
converted = jsdate_to_time(jsondict['enrollment_start'])
converted = date.from_json(jsondict['enrollment_start'])
else:
converted = None
@@ -107,7 +113,7 @@ class CourseDetails(object):
descriptor.enrollment_start = converted
if 'enrollment_end' in jsondict:
converted = jsdate_to_time(jsondict['enrollment_end'])
converted = date.from_json(jsondict['enrollment_end'])
else:
converted = None
@@ -116,7 +122,7 @@ class CourseDetails(object):
descriptor.enrollment_end = converted
if dirty:
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor))
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
# to make faster, could compare against db or could have client send over a list of which fields changed.
@@ -133,7 +139,6 @@ class CourseDetails(object):
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
update_item(temploc, recomposed_video_tag)
# Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm
# it persisted correctly
return CourseDetails.fetch(course_location)
@@ -178,6 +183,6 @@ class CourseSettingsEncoder(json.JSONEncoder):
elif isinstance(obj, Location):
return obj.dict()
elif isinstance(obj, time.struct_time):
return time_to_date(obj)
return Date().to_json(obj)
else:
return JSONEncoder.default(self, obj)

View File

@@ -1,7 +1,6 @@
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
import re
from util import converters
from datetime import timedelta
class CourseGradingModel(object):
@@ -91,7 +90,7 @@ class CourseGradingModel(object):
descriptor.raw_grader = graders_parsed
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
return CourseGradingModel.fetch(course_location)
@@ -119,7 +118,7 @@ class CourseGradingModel(object):
else:
descriptor.raw_grader.append(grader)
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
@@ -134,7 +133,7 @@ class CourseGradingModel(object):
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.grade_cutoffs = cutoffs
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return cutoffs
@@ -156,11 +155,11 @@ class CourseGradingModel(object):
graceperiodjson = graceperiodjson['grace_period']
# lms requires these to be in a fixed order
grace_rep = "{0[hours]:d} hours {0[minutes]:d} minutes {0[seconds]:d} seconds".format(graceperiodjson)
grace_timedelta = timedelta(**graceperiodjson)
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.metadata['graceperiod'] = grace_rep
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
descriptor.lms.graceperiod = grace_timedelta
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
@staticmethod
def delete_grader(course_location, index):
@@ -176,7 +175,7 @@ class CourseGradingModel(object):
del descriptor.raw_grader[index]
# force propagation to definition
descriptor.raw_grader = descriptor.raw_grader
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
# NOTE cannot delete cutoffs. May be useful to reset
@staticmethod
@@ -189,7 +188,7 @@ class CourseGradingModel(object):
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS']
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return descriptor.grade_cutoffs
@@ -202,8 +201,8 @@ class CourseGradingModel(object):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
if 'graceperiod' in descriptor.metadata: del descriptor.metadata['graceperiod']
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
del descriptor.lms.graceperiod
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
@staticmethod
def get_section_grader_type(location):
@@ -212,7 +211,7 @@ class CourseGradingModel(object):
descriptor = get_modulestore(location).get_item(location)
return {
"graderType": descriptor.metadata.get('format', u"Not Graded"),
"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
"location": location,
"id": 99 # just an arbitrary value to
}
@@ -224,23 +223,41 @@ class CourseGradingModel(object):
descriptor = get_modulestore(location).get_item(location)
if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded":
descriptor.metadata['format'] = jsondict.get('graderType')
descriptor.metadata['graded'] = True
descriptor.lms.format = jsondict.get('graderType')
descriptor.lms.graded = True
else:
if 'format' in descriptor.metadata: del descriptor.metadata['format']
if 'graded' in descriptor.metadata: del descriptor.metadata['graded']
del descriptor.lms.format
del descriptor.lms.graded
get_modulestore(location).update_metadata(location, descriptor.metadata)
get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
@staticmethod
def convert_set_grace_period(descriptor):
# 5 hours 59 minutes 59 seconds => { hours: 5, minutes : 59, seconds : 59}
rawgrace = descriptor.metadata.get('graceperiod', None)
# 5 hours 59 minutes 59 seconds => converted to iso format
rawgrace = descriptor.lms.graceperiod
if rawgrace:
parsedgrace = {str(key): int(val) for (val, key) in re.findall('\s*(\d+)\s*(\w+)', rawgrace)}
return parsedgrace
else: return None
hours_from_days = rawgrace.days*24
seconds = rawgrace.seconds
hours_from_seconds = int(seconds / 3600)
hours = hours_from_days + hours_from_seconds
seconds -= hours_from_seconds * 3600
minutes = int(seconds / 60)
seconds -= minutes * 60
graceperiod = {'hours': 0, 'minutes': 0, 'seconds': 0}
if hours > 0:
graceperiod['hours'] = hours
if minutes > 0:
graceperiod['minutes'] = minutes
if seconds > 0:
graceperiod['seconds'] = seconds
return graceperiod
else:
return None
@staticmethod
def parse_grader(json_grader):

View File

@@ -0,0 +1,97 @@
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore.inheritance import own_metadata
from xblock.core import Scope
from xmodule.course_module import CourseDescriptor
import copy
class CourseMetadata(object):
'''
For CRUD operations on metadata fields which do not have specific editors
on the other pages including any user generated ones.
The objects have no predefined attrs but instead are obj encodings of the
editable metadata.
'''
FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end',
'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', 'checklists']
@classmethod
def fetch(cls, course_location):
"""
Fetch the key:value editable course details for the given course from
persistence and return a CourseMetadata model.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
course = {}
descriptor = get_modulestore(course_location).get_item(course_location)
for field in descriptor.fields + descriptor.lms.fields:
if field.scope != Scope.settings:
continue
if field.name not in cls.FILTERED_LIST:
course[field.name] = field.read_json(descriptor)
return course
@classmethod
def update_from_json(cls, course_location, jsondict, filter_tabs=True):
"""
Decode the json into CourseMetadata and save any changed attrs to the db.
Ensures none of the fields are in the blacklist.
"""
descriptor = get_modulestore(course_location).get_item(course_location)
dirty = False
#Copy the filtered list to avoid permanently changing the class attribute
filtered_list = copy.copy(cls.FILTERED_LIST)
#Don't filter on the tab attribute if filter_tabs is False
if not filter_tabs:
filtered_list.remove("tabs")
for k, v in jsondict.iteritems():
# should it be an error if one of the filtered list items is in the payload?
if k in filtered_list:
continue
if hasattr(descriptor, k) and getattr(descriptor, k) != v:
dirty = True
value = getattr(CourseDescriptor, k).from_json(v)
setattr(descriptor, k, value)
elif hasattr(descriptor.lms, k) and getattr(descriptor.lms, k) != k:
dirty = True
value = getattr(CourseDescriptor.lms, k).from_json(v)
setattr(descriptor.lms, k, value)
if dirty:
get_modulestore(course_location).update_metadata(course_location,
own_metadata(descriptor))
# Could just generate and return a course obj w/o doing any db reads,
# but I put the reads in as a means to confirm it persisted correctly
return cls.fetch(course_location)
@classmethod
def delete_key(cls, course_location, payload):
'''
Remove the given metadata key(s) from the course. payload can be a
single key or [key..]
'''
descriptor = get_modulestore(course_location).get_item(course_location)
for key in payload['deleteKeys']:
if hasattr(descriptor, key):
delattr(descriptor, key)
elif hasattr(descriptor.lms, key):
delattr(descriptor.lms, key)
get_modulestore(course_location).update_metadata(course_location,
own_metadata(descriptor))
return cls.fetch(course_location)

View File

@@ -62,3 +62,6 @@ AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
DATABASES = AUTH_TOKENS['DATABASES']
MODULESTORE = AUTH_TOKENS['MODULESTORE']
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
# Datadog for events!
DATADOG_API = AUTH_TOKENS.get("DATADOG_API")

View File

@@ -20,7 +20,6 @@ Longer TODO:
"""
import sys
import tempfile
import os.path
import os
import lms.envs.common
@@ -59,7 +58,8 @@ sys.path.append(COMMON_ROOT / 'lib')
############################# WEB CONFIGURATION #############################
# This is where we stick our compiled template files.
MAKO_MODULE_DIR = tempfile.mkdtemp('mako')
from tempdir import mkdtemp_clean
MAKO_MODULE_DIR = mkdtemp_clean('mako')
MAKO_TEMPLATES = {}
MAKO_TEMPLATES['main'] = [
PROJECT_ROOT / 'templates',
@@ -74,8 +74,8 @@ TEMPLATE_DIRS = MAKO_TEMPLATES['main']
MITX_ROOT_URL = ''
LOGIN_REDIRECT_URL = MITX_ROOT_URL + '/login'
LOGIN_URL = MITX_ROOT_URL + '/login'
LOGIN_REDIRECT_URL = MITX_ROOT_URL + '/signin'
LOGIN_URL = MITX_ROOT_URL + '/signin'
TEMPLATE_CONTEXT_PROCESSORS = (
@@ -113,6 +113,7 @@ TEMPLATE_LOADERS = (
MIDDLEWARE_CLASSES = (
'contentserver.middleware.StaticContentServer',
'request_cache.middleware.RequestCache',
'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
@@ -172,6 +173,9 @@ LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identi
USE_I18N = True
USE_L10N = True
# Tracking
TRACK_MAX_EVENT = 10000
# Messages
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
@@ -275,6 +279,10 @@ INSTALLED_APPS = (
'auth',
'student', # misleading name due to sharing with lms
'course_groups', # not used in cms (yet), but tests run
# tracking
'track',
# For asset pipelining
'pipeline',
'staticfiles',

View File

@@ -4,9 +4,6 @@ This config file runs the simplest dev environment"""
from .common import *
from logsettings import get_logger_config
import logging
import sys
DEBUG = True
TEMPLATE_DEBUG = DEBUG
LOGGING = get_logger_config(ENV_ROOT / "log",
@@ -99,6 +96,13 @@ CACHES = {
'KEY_PREFIX': 'general',
'VERSION': 4,
'KEY_FUNCTION': 'util.memcache.safe_key',
},
'mongo_metadata_inheritance': {
'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
'LOCATION': '/var/tmp/mongo_metadata_inheritance',
'TIMEOUT': 300,
'KEY_FUNCTION': 'util.memcache.safe_key',
}
}
@@ -107,3 +111,39 @@ CACHE_TIMEOUT = 0
# Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
################################ PIPELINE #################################
PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT)
################################ DEBUG TOOLBAR #################################
INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo')
MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',)
INTERNAL_IPS = ('127.0.0.1',)
DEBUG_TOOLBAR_PANELS = (
'debug_toolbar.panels.version.VersionDebugPanel',
'debug_toolbar.panels.timer.TimerDebugPanel',
'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel',
'debug_toolbar.panels.headers.HeaderDebugPanel',
'debug_toolbar.panels.request_vars.RequestVarsDebugPanel',
'debug_toolbar.panels.sql.SQLDebugPanel',
'debug_toolbar.panels.signals.SignalDebugPanel',
'debug_toolbar.panels.logger.LoggingPanel',
'debug_toolbar_mongo.panel.MongoDebugPanel',
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
# hit twice). So you can uncomment when you need to diagnose performance
# problems, but you shouldn't leave it on.
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
)
DEBUG_TOOLBAR_CONFIG = {
'INTERCEPT_REDIRECTS': False
}
# To see stacktraces for MongoDB queries, set this to True.
# Stacktraces slow down page loads drastically (for pages with lots of queries).
DEBUG_TOOLBAR_MONGO_STACKTRACES = True

View File

@@ -27,6 +27,9 @@ STATIC_ROOT = TEST_ROOT / "staticfiles"
GITHUB_REPO_ROOT = TEST_ROOT / "data"
COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data"
# Makes the tests run much faster...
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
# TODO (cpennington): We need to figure out how envs/test.py can inject things into common.py so that we don't have to repeat this sort of thing
STATICFILES_DIRS = [
COMMON_ROOT / "static",
@@ -55,6 +58,10 @@ MODULESTORE = {
'direct': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': modulestore_options
},
'draft': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'OPTIONS': modulestore_options
}
}
@@ -95,6 +102,13 @@ CACHES = {
'KEY_PREFIX': 'general',
'VERSION': 4,
'KEY_FUNCTION': 'util.memcache.safe_key',
},
'mongo_metadata_inheritance': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': '/var/tmp/mongo_metadata_inheritance',
'TIMEOUT': 300,
'KEY_FUNCTION': 'util.memcache.safe_key',
}
}

16
cms/one_time_startup.py Normal file
View File

@@ -0,0 +1,16 @@
from dogapi import dog_http_api, dog_stats_api
from django.conf import settings
from xmodule.modulestore.django import modulestore
from request_cache.middleware import RequestCache
from django.core.cache import get_cache, InvalidCacheBackendError
cache = get_cache('mongo_metadata_inheritance')
for store_name in settings.MODULESTORE:
store = modulestore(store_name)
store.metadata_inheritance_cache_subsystem = cache
store.request_cache = RequestCache.get_request_cache()
if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True)

View File

@@ -0,0 +1,11 @@
<li class="field-group course-advanced-policy-list-item">
<div class="field is-not-editable text key" id="<%= key %>">
<label for="<%= keyUniqueId %>">Policy Key:</label>
<input readonly title="This field is disabled: policy keys cannot be edited." type="text" class="short policy-key" id="<%= keyUniqueId %>" value="<%= key %>" />
</div>
<div class="field text value">
<label for="<%= valueUniqueId %>">Policy Value:</label>
<textarea class="json text" id="<%= valueUniqueId %>"><%= value %></textarea>
</div>
</li>

View File

@@ -0,0 +1,61 @@
<% var allChecked = itemsChecked == items.length; %>
<section
<% if (allChecked) { %>
class="course-checklist is-completed"
<% } else { %>
class="course-checklist"
<% } %>
id="<%= 'course-checklist' + checklistIndex %>">
<% var widthPercentage = 'width:' + percentChecked + '%;'; %>
<span class="viz viz-checklist-status"><span class="viz value viz-checklist-status-value" style="<%= widthPercentage %>">
<span class="int"><%= percentChecked %></span>% of checklist completed</span></span>
<header>
<h3 class="checklist-title title-2 is-selectable" title="Collapse/Expand this Checklist">
<i class="ss-icon ss-symbolicons-standard icon-arrow ui-toggle-expansion">&#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>

View File

@@ -1,69 +1,37 @@
<li class="input input-existing multi course-grading-assignment-list-item">
<div class="row row-col2">
<label for="course-grading-assignment-name">Assignment Type Name:</label>
<li class="field-group course-grading-assignment-list-item">
<div class="field text" id="field-course-grading-assignment-name">
<label for="course-grading-assignment-name">Assignment Type Name</label>
<input type="text" class="long" id="course-grading-assignment-name" value="<%= model.get('type') %>" />
<span class="tip tip-stacked">e.g. Homework, Midterm Exams</span>
</div>
<div class="field">
<div class="input course-grading-assignment-name">
<input type="text" class="long"
id="course-grading-assignment-name" value="<%= model.get('type') %>">
<span class="tip tip-stacked">e.g. Homework, Labs, Midterm Exams, Final Exam</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-shortname">Abbreviation:</label>
<div class="field">
<div class="input course-grading-shortname">
<input type="text" class="short"
id="course-grading-assignment-shortname"
value="<%= model.get('short_label') %>">
<span class="tip tip-inline">e.g. HW, Midterm, Final</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-gradeweight">Weight of Total
Grade:</label>
<div class="field">
<div class="input course-grading-gradeweight">
<input type="text" class="short"
id="course-grading-assignment-gradeweight"
value = "<%= model.get('weight') %>">
<span class="tip tip-inline">e.g. 25%</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-totalassignments">Total
Number:</label>
<div class="field">
<div class="input course-grading-totalassignments">
<input type="text" class="short"
id="course-grading-assignment-totalassignments"
value = "<%= model.get('min_count') %>">
<span class="tip tip-inline">total exercises assigned</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-droppable">Number of
Droppable:</label>
<div class="field">
<div class="input course-grading-droppable">
<input type="text" class="short"
id="course-grading-assignment-droppable"
value = "<%= model.get('drop_count') %>">
<span class="tip tip-inline">total exercises that won't be graded</span>
</div>
</div>
</div>
<a href="#" class="delete-button standard remove-item remove-grading-data"><span class="delete-icon"></span>Delete</a>
<div class="field text" id="field-course-grading-assignment-shortname">
<label for="course-grading-assignment-shortname">Abbreviation:</label>
<input type="text" class="short" id="course-grading-assignment-shortname" value="<%= model.get('short_label') %>" />
<span class="tip tip-inline">e.g. HW, Midterm</span>
</div>
<div class="field text" id="field-course-grading-assignment-gradeweight">
<label for="course-grading-assignment-gradeweight">Weight of Total Grade</label>
<input type="text" class="short" id="course-grading-assignment-gradeweight" value = "<%= model.get('weight') %>" />
<span class="tip tip-inline">e.g. 25%</span>
</div>
<div class="field text" id="field-course-grading-assignment-totalassignments">
<label for="course-grading-assignment-totalassignments">Total
Number</label>
<input type="text" class="short" id="course-grading-assignment-totalassignments" value = "<%= model.get('min_count') %>" />
<span class="tip tip-inline">total exercises assigned</span>
</div>
<div class="field text" id="field-course-grading-assignment-droppable">
<label for="course-grading-assignment-droppable">Number of
Droppable</label>
<input type="text" class="short" id="course-grading-assignment-droppable" value = "<%= model.get('drop_count') %>" />
<span class="tip tip-inline">total exercises that won't be graded</span>
</div>
<div class="actions">
<a href="#" class="button delete-button standard remove-item remove-grading-data"><span class="delete-icon"></span>Delete</a>
</div>
</li>

View File

@@ -1,12 +1,12 @@
{
"js_files": [
"/static/js/vendor/RequireJS.js",
"/static/js/vendor/jquery.min.js",
"/static/js/vendor/jquery-ui.min.js",
"/static/js/vendor/jquery.ui.draggable.js",
"/static/js/vendor/jquery.cookie.js",
"/static/js/vendor/json2.js",
"/static/js/vendor/underscore-min.js",
"/static/js/vendor/backbone-min.js"
"static_files": [
"js/vendor/RequireJS.js",
"js/vendor/jquery.min.js",
"js/vendor/jquery-ui.min.js",
"js/vendor/jquery.ui.draggable.js",
"js/vendor/jquery.cookie.js",
"js/vendor/json2.js",
"js/vendor/underscore-min.js",
"js/vendor/backbone-min.js"
]
}

View File

@@ -1,6 +1,4 @@
class CMS.Views.TabsEdit extends Backbone.View
events:
'click .new-tab': 'addNewTab'
initialize: =>
@$('.component').each((idx, element) =>
@@ -13,6 +11,7 @@ class CMS.Views.TabsEdit extends Backbone.View
)
)
@options.mast.find('.new-tab').on('click', @addNewTab)
@$('.components').sortable(
handle: '.drag-handle'
update: @tabMoved

View File

@@ -34,7 +34,10 @@ class CMS.Views.UnitEdit extends Backbone.View
@$('.components').sortable(
handle: '.drag-handle'
update: (event, ui) => @model.save(children: @components())
update: (event, ui) =>
payload = children : @components()
options = success : => @model.unset('children')
@model.save(payload, options)
helper: 'clone'
opacity: '0.5'
placeholder: 'component-placeholder'
@@ -109,7 +112,14 @@ class CMS.Views.UnitEdit extends Backbone.View
id: $component.data('id')
}, =>
$component.remove()
@model.save(children: @components())
# b/c we don't vigilantly keep children up to date
# get rid of it before it hurts someone
# sorry for the js, i couldn't figure out the coffee equivalent
`_this.model.save({children: _this.components()},
{success: function(model) {
model.unset('children');
}}
);`
)
deleteDraft: (event) ->
@@ -157,7 +167,7 @@ class CMS.Views.UnitEdit extends Backbone.View
class CMS.Views.UnitEdit.NameEdit extends Backbone.View
events:
"keyup .unit-display-name-input": "saveName"
'change .unit-display-name-input': 'saveName'
initialize: =>
@model.on('change:metadata', @render)
@@ -180,29 +190,10 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View
# Treat the metadata dictionary as immutable
metadata = $.extend({}, @model.get('metadata'))
metadata.display_name = @$('.unit-display-name-input').val()
@model.save(metadata: metadata)
# Update name shown in the right-hand side location summary.
$('.unit-location .editing .unit-name').html(metadata.display_name)
inputField = this.$el.find('input')
# add a spinner
@$spinner.css({
'position': 'absolute',
'top': Math.floor(inputField.position().top + (inputField.outerHeight() / 2) + 3),
'left': inputField.position().left + inputField.outerWidth() - 24,
'margin-top': '-10px'
});
inputField.after(@$spinner);
@$spinner.fadeIn(10)
# save the name after a slight delay
if @timer
clearTimeout @timer
@timer = setTimeout( =>
@model.save(metadata: metadata)
@timer = null
@$spinner.delay(500).fadeOut(150)
, 500)
class CMS.Views.UnitEdit.LocationState extends Backbone.View
initialize: =>
@model.on('change:state', @render)

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 797 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 B

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 925 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -5,7 +5,7 @@ var $newComponentItem;
var $changedInput;
var $spinner;
$(document).ready(function() {
$(document).ready(function () {
$body = $('body');
$modal = $('.history-modal');
$modalCover = $('<div class="modal-cover">');
@@ -13,10 +13,6 @@ $(document).ready(function() {
// 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 course-info tab)
window.$modalCover = $modalCover;
// Control whether template caching in local memory occurs (see template_loader.js). Caching screws up development but may
// be a good optimization in production (it works fairly well)
window.cachetemplates = false;
$body.append($modalCover);
$newComponentItem = $('.new-component-item');
@@ -31,25 +27,74 @@ $(document).ready(function() {
$modal.bind('click', hideModal);
$modalCover.bind('click', hideModal);
$('.assets .upload-button').bind('click', showUploadModal);
$('.uploads .upload-button').bind('click', showUploadModal);
$('.upload-modal .close-button').bind('click', hideModal);
$body.on('click', '.embeddable-xml-input', function(){ $(this).select(); });
$body.on('click', '.embeddable-xml-input', function () {
$(this).select();
});
$('.unit .item-actions .delete-button').bind('click', deleteUnit);
$('.new-unit-item').bind('click', createNewUnit);
$('body').addClass('js');
// lean/simple modal
$('a[rel*=modal]').leanModal({overlay : 0.80, closeButton: '.action-modal-close' });
$('a.action-modal-close').click(function(e){
(e).preventDefault();
});
// nav - dropdown related
$body.click(function (e) {
$('.nav-dropdown .nav-item .wrapper-nav-sub').removeClass('is-shown');
$('.nav-dropdown .nav-item .title').removeClass('is-selected');
});
$('.nav-dropdown .nav-item .title').click(function (e) {
$subnav = $(this).parent().find('.wrapper-nav-sub');
$title = $(this).parent().find('.title');
e.preventDefault();
e.stopPropagation();
if ($subnav.hasClass('is-shown')) {
$subnav.removeClass('is-shown');
$title.removeClass('is-selected');
}
else {
$('.nav-dropdown .nav-item .title').removeClass('is-selected');
$('.nav-dropdown .nav-item .wrapper-nav-sub').removeClass('is-shown');
$title.addClass('is-selected');
$subnav.addClass('is-shown');
}
});
// general link management - new window/tab
$('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').bind('click', linkNewWindow);
// general link management - lean modal window
$('a[rel="modal"]').attr('title', 'This link will open in a modal window').leanModal({overlay: 0.50, closeButton: '.action-modal-close' });
$('.action-modal-close').click(function (e) {
(e).preventDefault();
});
// general link management - smooth scrolling page links
$('a[rel*="view"][href^="#"]').bind('click', smoothScrollLink);
// toggling overview section details
$(function(){
if($('.courseware-section').length > 0) {
$('.toggle-button-sections').addClass('is-shown');
}
$(function () {
if ($('.courseware-section').length > 0) {
$('.toggle-button-sections').addClass('is-shown');
}
});
$('.toggle-button-sections').bind('click', toggleSections);
// autosave when a field is updated on the subsection page
$body.on('keyup', '.subsection-display-name-input, .unit-subtitle, .policy-list-value', checkForNewValue);
$('.subsection-display-name-input, .unit-subtitle, .policy-list-name, .policy-list-value').each(function(i) {
// autosave when leaving input field
$body.on('change', '.subsection-display-name-input', saveSubsection);
$('.subsection-display-name-input').each(function () {
this.val = $(this).val();
});
$("#start_date, #start_time, #due_date, #due_time").bind('change', autosaveInput);
@@ -61,21 +106,16 @@ $(document).ready(function() {
// add new/delete section
$('.new-courseware-section-button').bind('click', addNewSection);
$('.delete-section-button').bind('click', deleteSection);
// add new/delete subsection
$('.new-subsection-item').bind('click', addNewSubsection);
$('.delete-subsection-button').bind('click', deleteSubsection);
// add/remove policy metadata button click handlers
$('.add-policy-data').bind('click', addPolicyMetadata);
$('.remove-policy-data').bind('click', removePolicyMetadata);
$body.on('click', '.policy-list-element .save-button', savePolicyMetadata);
$body.on('click', '.policy-list-element .cancel-button', cancelPolicyMetadata);
$('.sync-date').bind('click', syncReleaseDate);
// import form setup
$('.import .file-input').bind('change', showImportSubmit);
$('.import .choose-file-button, .import .choose-file-button-inline').bind('click', function(e) {
$('.import .choose-file-button, .import .choose-file-button-inline').bind('click', function (e) {
e.preventDefault();
$('.import .file-input').click();
});
@@ -98,42 +138,59 @@ $(document).ready(function() {
$body.on('click', '.section-published-date .schedule-button', editSectionPublishDate);
$body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate);
$body.on('click', '.edit-subsection-publish-settings .cancel-button', hideModal);
$body.on('change', '.edit-subsection-publish-settings .start-date', function() {
if($('.edit-subsection-publish-settings').find('.start-time').val() == '') {
$('.edit-subsection-publish-settings').find('.start-time').val('12:00am');
$body.on('change', '.edit-subsection-publish-settings .start-date', function () {
if ($('.edit-subsection-publish-settings').find('.start-time').val() == '') {
$('.edit-subsection-publish-settings').find('.start-time').val('12:00am');
}
});
$('.edit-subsection-publish-settings').on('change', '.start-date, .start-time', function() {
$('.edit-subsection-publish-settings').on('change', '.start-date, .start-time', function () {
$('.edit-subsection-publish-settings').find('.save-button').show();
});
});
// function collapseAll(e) {
// $('.branch').addClass('collapsed');
// $('.expand-collapse-icon').removeClass('collapse').addClass('expand');
// }
function smoothScrollLink(e) {
(e).preventDefault();
$.smoothScroll({
offset: -200,
easing: 'swing',
speed: 1000,
scrollElement: null,
scrollTarget: $(this).attr('href')
});
}
function linkNewWindow(e) {
window.open($(e.target).attr('href'));
e.preventDefault();
}
// On AWS instances, base.js gets wrapped in a separate scope as part of Django static
// pipelining (note, this doesn't happen on local runtimes). So if we set it on window,
// when we can access it from other scopes (namely the checklists)
window.cmsLinkNewWindow = linkNewWindow;
function toggleSections(e) {
e.preventDefault();
e.preventDefault();
$section = $('.courseware-section');
sectionCount = $section.length;
$button = $(this);
$labelCollapsed = $('<i class="ss-icon ss-symbolicons-block">up</i> <span class="label">Collapse All Sections</span>');
$labelExpanded = $('<i class="ss-icon ss-symbolicons-block">down</i> <span class="label">Expand All Sections</span>');
$section = $('.courseware-section');
sectionCount = $section.length;
$button = $(this);
$labelCollapsed = $('<i class="ss-icon ss-symbolicons-block">up</i> <span class="label">Collapse All Sections</span>');
$labelExpanded = $('<i class="ss-icon ss-symbolicons-block">down</i> <span class="label">Expand All Sections</span>');
var buttonLabel = $button.hasClass('is-activated') ? $labelCollapsed : $labelExpanded;
$button.toggleClass('is-activated').html(buttonLabel);
var buttonLabel = $button.hasClass('is-activated') ? $labelCollapsed : $labelExpanded;
$button.toggleClass('is-activated').html(buttonLabel);
if($button.hasClass('is-activated')) {
$section.addClass('collapsed');
// first child in order to avoid the icons on the subsection lists which are not in the first child
$section.find('header .expand-collapse-icon').removeClass('collapse').addClass('expand');
} else {
$section.removeClass('collapsed');
// first child in order to avoid the icons on the subsection lists which are not in the first child
$section.find('header .expand-collapse-icon').removeClass('expand').addClass('collapse');
}
if ($button.hasClass('is-activated')) {
$section.addClass('collapsed');
// first child in order to avoid the icons on the subsection lists which are not in the first child
$section.find('header .expand-collapse-icon').removeClass('collapse').addClass('expand');
} else {
$section.removeClass('collapsed');
// first child in order to avoid the icons on the subsection lists which are not in the first child
$section.find('header .expand-collapse-icon').removeClass('expand').addClass('collapse');
}
}
function editSectionPublishDate(e) {
@@ -143,16 +200,16 @@ function editSectionPublishDate(e) {
$modal.attr('data-id', $(this).attr('data-id'));
$modal.find('.start-date').val($(this).attr('data-date'));
$modal.find('.start-time').val($(this).attr('data-time'));
if($modal.find('.start-date').val() == '' && $modal.find('.start-time').val() == '') {
if ($modal.find('.start-date').val() == '' && $modal.find('.start-time').val() == '') {
$modal.find('.save-button').hide();
}
}
$modal.find('.section-name').html('"' + $(this).closest('.courseware-section').find('.section-name-span').text() + '"');
$modalCover.show();
}
function showImportSubmit(e) {
var filepath = $(this).val();
if(filepath.substr(filepath.length - 6, 6) == 'tar.gz') {
if (filepath.substr(filepath.length - 6, 6) == 'tar.gz') {
$('.error-block').hide();
$('.file-name').html($(this).val().replace('C:\\fakepath\\', ''));
$('.file-name-block').show();
@@ -171,65 +228,15 @@ function syncReleaseDate(e) {
$("#start_time").val("");
}
function addPolicyMetadata(e) {
e.preventDefault();
var template =$('#add-new-policy-element-template > li');
var newNode = template.clone();
var _parent_el = $(this).parent('ol:.policy-list');
newNode.insertBefore('.add-policy-data');
$('.remove-policy-data').bind('click', removePolicyMetadata);
newNode.find('.policy-list-name').focus();
}
function savePolicyMetadata(e) {
e.preventDefault();
var $policyElement = $(this).parents('.policy-list-element');
saveSubsection()
$policyElement.removeClass('new-policy-list-element');
$policyElement.find('.policy-list-name').attr('disabled', 'disabled');
$policyElement.removeClass('editing');
}
function cancelPolicyMetadata(e) {
e.preventDefault();
var $policyElement = $(this).parents('.policy-list-element');
if(!$policyElement.hasClass('editing')) {
$policyElement.remove();
} else {
$policyElement.removeClass('new-policy-list-element');
$policyElement.find('.policy-list-name').val($policyElement.data('currentValues')[0]);
$policyElement.find('.policy-list-value').val($policyElement.data('currentValues')[1]);
}
$policyElement.removeClass('editing');
}
function removePolicyMetadata(e) {
e.preventDefault();
if(!confirm('Are you sure you wish to delete this item. It cannot be reversed!'))
return;
policy_name = $(this).data('policy-name');
var _parent_el = $(this).parent('li:.policy-list-element');
if ($(_parent_el).hasClass("new-policy-list-element")) {
_parent_el.remove();
} else {
_parent_el.appendTo("#policy-to-delete");
}
saveSubsection()
}
function getEdxTimeFromDateTimeVals(date_val, time_val, format) {
var edxTimeStr = null;
if (date_val != '') {
if (time_val == '')
if (time_val == '')
time_val = '00:00';
// Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing
date = Date.parse(date_val + " " + time_val);
var date = Date.parse(date_val + " " + time_val);
if (format == null)
format = 'yyyy-MM-ddTHH:mm';
@@ -240,51 +247,28 @@ function getEdxTimeFromDateTimeVals(date_val, time_val, format) {
}
function getEdxTimeFromDateTimeInputs(date_id, time_id, format) {
var input_date = $('#'+date_id).val();
var input_time = $('#'+time_id).val();
var input_date = $('#' + date_id).val();
var input_time = $('#' + time_id).val();
return getEdxTimeFromDateTimeVals(input_date, input_time, format);
}
function checkForNewValue(e) {
if($(this).parents('.new-policy-list-element')[0]) {
return;
}
if(this.val) {
this.hasChanged = this.val != $(this).val();
} else {
this.hasChanged = false;
}
this.val = $(this).val();
if(this.hasChanged) {
if(this.saveTimer) {
clearTimeout(this.saveTimer);
}
this.saveTimer = setTimeout(function() {
$changedInput = $(e.target);
saveSubsection();
this.saveTimer = null;
}, 500);
}
}
function autosaveInput(e) {
if(this.saveTimer) {
var self = this;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
}
this.saveTimer = setTimeout(function() {
this.saveTimer = setTimeout(function () {
$changedInput = $(e.target);
saveSubsection();
this.saveTimer = null;
self.saveTimer = null;
}, 500);
}
function saveSubsection() {
if($changedInput && !$changedInput.hasClass('no-spinner')) {
// Spinner is no longer used by subsection name, but is still used by date and time pickers on the right.
if ($changedInput && !$changedInput.hasClass('no-spinner')) {
$spinner.css({
'position': 'absolute',
'top': Math.floor($changedInput.position().top + ($changedInput.outerHeight() / 2) + 3),
@@ -294,31 +278,17 @@ function saveSubsection() {
$changedInput.after($spinner);
$spinner.show();
}
var id = $('.subsection-body').data('id');
// pull all 'normalized' metadata editable fields on page
var metadata_fields = $('input[data-metadata-name]');
var metadata = {};
for(var i=0; i< metadata_fields.length;i++) {
var el = metadata_fields[i];
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;
});
for (var i = 0; i < metadata_fields.length; i++) {
var el = metadata_fields[i];
metadata[$(el).data("metadata-name")] = el.value;
}
// 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
@@ -327,18 +297,19 @@ function saveSubsection() {
metadata['due'] = getEdxTimeFromDateTimeInputs('due_date', 'due_time', 'MMMM dd HH:mm');
$.ajax({
url: "/save_item",
type: "POST",
dataType: "json",
contentType: "application/json",
data:JSON.stringify({ 'id' : id, 'metadata' : metadata}),
success: function() {
url: "/save_item",
type: "POST",
dataType: "json",
contentType: "application/json",
data: JSON.stringify({ 'id': id, 'metadata': metadata}),
success: function () {
$spinner.delay(500).fadeOut(150);
},
error: function() {
$changedInput = null;
},
error: function () {
showToastMessage('There has been an error while saving your changes.');
}
});
}
});
}
@@ -349,14 +320,14 @@ function createNewUnit(e) {
template = $(this).data('template');
$.post('/clone_item',
{'parent_location' : parent,
'template' : template,
'display_name': 'New Unit'
},
function(data) {
// redirect to the edit page
window.location = "/edit/" + data['id'];
});
{'parent_location': parent,
'template': template,
'display_name': 'New Unit'
},
function (data) {
// redirect to the edit page
window.location = "/edit/" + data['id'];
});
}
function deleteUnit(e) {
@@ -375,16 +346,16 @@ function deleteSection(e) {
}
function _deleteItem($el) {
if(!confirm('Are you sure you wish to delete this item. It cannot be reversed!'))
return;
if (!confirm('Are you sure you wish to delete this item. It cannot be reversed!'))
return;
var id = $el.data('id');
$.post('/delete_item',
{'id': id, 'delete_children' : true, 'delete_all_versions' : true},
function(data) {
$el.remove();
});
$.post('/delete_item',
{'id': id, 'delete_children': true, 'delete_all_versions': true},
function (data) {
$el.remove();
});
}
function showUploadModal(e) {
@@ -411,7 +382,7 @@ function startUpload(e) {
$('.upload-modal .progress-bar').removeClass('loaded').show();
}
function resetUploadBar(){
function resetUploadBar() {
var percentVal = '0%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
@@ -424,7 +395,7 @@ function showUploadFeedback(event, position, total, percentComplete) {
}
function displayFinishedUpload(xhr) {
if(xhr.status = 200){
if (xhr.status = 200) {
markAsLoaded();
}
@@ -448,10 +419,10 @@ function displayFinishedUpload(xhr) {
function markAsLoaded() {
$('.upload-modal .copy-button').css('display', 'inline-block');
$('.upload-modal .progress-bar').addClass('loaded');
}
}
function hideModal(e) {
if(e) {
if (e) {
e.preventDefault();
}
// Unit editors do not want the modal cover to hide when users click outside
@@ -465,7 +436,7 @@ function hideModal(e) {
}
function onKeyUp(e) {
if(e.which == 87) {
if (e.which == 87) {
$body.toggleClass('show-wip hide-wip');
}
}
@@ -515,14 +486,14 @@ function showToastMessage(message, $button, lifespan) {
var $content = $('<div class="notification-content"></div>');
$content.html(message);
$toast.append($content);
if($button) {
if ($button) {
$button.addClass('action-button');
$button.bind('click', hideToastMessage);
$content.append($button);
}
$closeBtn.bind('click', hideToastMessage);
if($('.toast-notification')[0]) {
if ($('.toast-notification')[0]) {
var targetY = $('.toast-notification').offset().top + $('.toast-notification').outerHeight();
$toast.css('top', (targetY + 10) + 'px');
}
@@ -530,8 +501,8 @@ function showToastMessage(message, $button, lifespan) {
$body.prepend($toast);
$toast.fadeIn(200);
if(lifespan) {
$toast.timer = setTimeout(function() {
if (lifespan) {
$toast.timer = setTimeout(function () {
$toast.fadeOut(300);
}, lifespan * 1000);
}
@@ -557,7 +528,7 @@ function addNewSection(e, isTemplate) {
}
function checkForCancel(e) {
if(e.which == 27) {
if (e.which == 27) {
$body.unbind('keyup', checkForCancel);
e.data.$cancelButton.click();
}
@@ -573,11 +544,11 @@ function saveNewSection(e) {
var display_name = $(this).find('.new-section-name').val();
$.post('/clone_item', {
'parent_location' : parent,
'template' : template,
'parent_location': parent,
'template': template,
'display_name': display_name,
},
function(data) {
function (data) {
if (data.id != undefined)
location.reload();
}
@@ -596,7 +567,7 @@ function addNewCourse(e) {
$(e.target).hide();
var $newCourse = $($('#new-course-template').html());
var $cancelButton = $newCourse.find('.new-course-cancel');
$('.new-course-button').after($newCourse);
$('.inner-wrapper').prepend($newCourse);
$newCourse.find('.new-course-name').focus().select();
$newCourse.find('form').bind('submit', saveNewCourse);
$cancelButton.bind('click', cancelNewCourse);
@@ -612,18 +583,18 @@ function saveNewCourse(e) {
var number = $newCourse.find('.new-course-number').val();
var display_name = $newCourse.find('.new-course-name').val();
if (org == '' || number == '' || display_name == ''){
if (org == '' || number == '' || display_name == '') {
alert('You must specify all fields in order to create a new course.');
return;
}
$.post('/create_new_course', {
'template' : template,
'org' : org,
'number' : number,
'display_name': display_name
'template': template,
'org': org,
'number': number,
'display_name': display_name
},
function(data) {
function (data) {
if (data.id != undefined) {
window.location = '/' + data.id.replace(/.*:\/\//, '');
} else if (data.ErrMsg != undefined) {
@@ -667,13 +638,13 @@ function saveNewSubsection(e) {
var display_name = $(this).find('.new-subsection-name-input').val();
$.post('/clone_item', {
'parent_location' : parent,
'template' : template,
'display_name': display_name
'parent_location': parent,
'template': template,
'display_name': display_name
},
function(data) {
function (data) {
if (data.id != undefined) {
location.reload();
location.reload();
}
}
);
@@ -720,21 +691,20 @@ function saveEditSectionName(e) {
}
var $_this = $(this);
// call into server to commit the new order
// call into server to commit the new order
$.ajax({
url: "/save_item",
type: "POST",
dataType: "json",
contentType: "application/json",
data:JSON.stringify({ 'id' : id, 'metadata' : {'display_name' : display_name}})
}).success(function()
{
$spinner.delay(250).fadeOut(250);
$_this.closest('h3').find('.section-name-span').html(display_name).show();
$_this.hide();
$_this.closest('.section-name').bind('click', editSectionName);
e.stopPropagation();
});
data: JSON.stringify({ 'id': id, 'metadata': {'display_name': display_name}})
}).success(function () {
$spinner.delay(250).fadeOut(250);
$_this.closest('h3').find('.section-name-span').html(display_name).show();
$_this.hide();
$_this.closest('.section-name').bind('click', editSectionName);
e.stopPropagation();
});
}
function setSectionScheduleDate(e) {
@@ -765,21 +735,20 @@ function saveSetSectionScheduleDate(e) {
type: "POST",
dataType: "json",
contentType: "application/json",
data:JSON.stringify({ 'id' : id, 'metadata' : {'start' : start}})
}).success(function()
{
var $thisSection = $('.courseware-section[data-id="' + id + '"]');
$thisSection.find('.section-published-date').html('<span class="published-status"><strong>Will Release:</strong> ' + input_date + ' at ' + input_time + '</span><a href="#" class="edit-button" data-date="' + input_date + '" data-time="' + input_time + '" data-id="' + id + '">Edit</a>');
$thisSection.find('.section-published-date').animate({
'background-color': 'rgb(182,37,104)'
}, 300).animate({
'background-color': '#edf1f5'
}, 300).animate({
'background-color': 'rgb(182,37,104)'
}, 300).animate({
'background-color': '#edf1f5'
}, 300);
hideModal();
});
}
data: JSON.stringify({ 'id': id, 'metadata': {'start': start}})
}).success(function () {
var $thisSection = $('.courseware-section[data-id="' + id + '"]');
$thisSection.find('.section-published-date').html('<span class="published-status"><strong>Will Release:</strong> ' + input_date + ' at ' + input_time + '</span><a href="#" class="edit-button" data-date="' + input_date + '" data-time="' + input_time + '" data-id="' + id + '">Edit</a>');
$thisSection.find('.section-published-date').animate({
'background-color': 'rgb(182,37,104)'
}, 300).animate({
'background-color': '#edf1f5'
}, 300).animate({
'background-color': 'rgb(182,37,104)'
}, 300).animate({
'background-color': '#edf1f5'
}, 300);
hideModal();
});
}

View File

@@ -0,0 +1,24 @@
// Model for checklists_view.js.
CMS.Models.Checklist = Backbone.Model.extend({
});
CMS.Models.ChecklistCollection = Backbone.Collection.extend({
model : CMS.Models.Checklist,
parse: function(response) {
_.each(response,
function( element, idx ) {
element.id = idx;
});
return response;
},
// Disable caching so the browser back button will work (checklists have links to other
// places within Studio).
fetch: function (options) {
options.cache = false;
return Backbone.Collection.prototype.fetch.call(this, options);
}
});

View File

@@ -0,0 +1,50 @@
if (!CMS.Models['Settings']) CMS.Models.Settings = {};
CMS.Models.Settings.Advanced = Backbone.Model.extend({
defaults: {
// the properties are whatever the user types in (in addition to whatever comes originally from the server)
},
// which keys to send as the deleted keys on next save
deleteKeys : [],
validate: function (attrs) {
// Keys can no longer be edited. We are currently not validating values.
},
save : function (attrs, options) {
// wraps the save call w/ the deletion of the removed keys after we know the saved ones worked
options = options ? _.clone(options) : {};
// add saveSuccess to the success
var success = options.success;
options.success = function(model, resp, options) {
model.afterSave(model);
if (success) success(model, resp, options);
};
Backbone.Model.prototype.save.call(this, attrs, options);
},
afterSave : function(self) {
// remove deleted attrs
if (!_.isEmpty(self.deleteKeys)) {
// remove the to be deleted keys from the returned model
_.each(self.deleteKeys, function(key) { self.unset(key); });
// not able to do via backbone since we're not destroying the model
$.ajax({
url : self.url,
// json to and fro
contentType : "application/json",
dataType : "json",
// delete
type : 'DELETE',
// data
data : JSON.stringify({ deleteKeys : self.deleteKeys})
})
.fail(function(hdr, status, error) { CMS.ServerError(self, "Deleting keys:" + status); })
.done(function(data, status, error) {
// clear deleteKeys on success
self.deleteKeys = [];
});
}
}
});

View File

@@ -1,85 +1,81 @@
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
defaults: {
location : null, // the course's Location model, required
start_date: null, // maps to 'start'
end_date: null, // maps to 'end'
enrollment_start: null,
enrollment_end: null,
syllabus: null,
overview: "",
intro_video: null,
effort: null // an int or null
},
// When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset)
parse: function(attributes) {
if (attributes['course_location']) {
attributes.location = new CMS.Models.Location(attributes.course_location, {parse:true});
}
if (attributes['start_date']) {
attributes.start_date = new Date(attributes.start_date);
}
if (attributes['end_date']) {
attributes.end_date = new Date(attributes.end_date);
}
if (attributes['enrollment_start']) {
attributes.enrollment_start = new Date(attributes.enrollment_start);
}
if (attributes['enrollment_end']) {
attributes.enrollment_end = new Date(attributes.enrollment_end);
}
return attributes;
},
validate: function(newattrs) {
// Returns either nothing (no return call) so that validate works or an object of {field: errorstring} pairs
// A bit funny in that the video key validation is asynchronous; so, it won't stop the validation.
var errors = {};
if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) {
errors.end_date = "The course end date cannot be before the course start date.";
}
if (newattrs.start_date && newattrs.enrollment_start && newattrs.start_date < newattrs.enrollment_start) {
errors.enrollment_start = "The course start date cannot be before the enrollment start date.";
}
if (newattrs.enrollment_start && newattrs.enrollment_end && newattrs.enrollment_start >= newattrs.enrollment_end) {
errors.enrollment_end = "The enrollment start date cannot be after the enrollment end date.";
}
if (newattrs.end_date && newattrs.enrollment_end && newattrs.end_date < newattrs.enrollment_end) {
errors.enrollment_end = "The enrollment end date cannot be after the course end date.";
}
if (newattrs.intro_video && newattrs.intro_video !== this.get('intro_video')) {
if (this._videokey_illegal_chars.exec(newattrs.intro_video)) {
errors.intro_video = "Key should only contain letters, numbers, _, or -";
}
// TODO check if key points to a real video using google's youtube api
}
if (!_.isEmpty(errors)) return errors;
// NOTE don't return empty errors as that will be interpreted as an error state
},
url: function() {
var location = this.get('location');
return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/details';
},
_videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
save_videosource: function(newsource) {
// newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string
// returns the videosource for the preview which iss the key whose speed is closest to 1
if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.save({'intro_video': null},
{ error : CMS.ServerError});
// TODO remove all whitespace w/in string
else {
if (this.get('intro_video') !== newsource) this.save('intro_video', newsource,
{ error : CMS.ServerError});
}
return this.videosourceSample();
},
videosourceSample : function() {
if (this.has('intro_video')) return "http://www.youtube.com/embed/" + this.get('intro_video');
else return "";
}
defaults: {
location : null, // the course's Location model, required
start_date: null, // maps to 'start'
end_date: null, // maps to 'end'
enrollment_start: null,
enrollment_end: null,
syllabus: null,
overview: "",
intro_video: null,
effort: null // an int or null
},
// When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset)
parse: function(attributes) {
if (attributes['course_location']) {
attributes.location = new CMS.Models.Location(attributes.course_location, {parse:true});
}
if (attributes['start_date']) {
attributes.start_date = new Date(attributes.start_date);
}
if (attributes['end_date']) {
attributes.end_date = new Date(attributes.end_date);
}
if (attributes['enrollment_start']) {
attributes.enrollment_start = new Date(attributes.enrollment_start);
}
if (attributes['enrollment_end']) {
attributes.enrollment_end = new Date(attributes.enrollment_end);
}
return attributes;
},
validate: function(newattrs) {
// Returns either nothing (no return call) so that validate works or an object of {field: errorstring} pairs
// A bit funny in that the video key validation is asynchronous; so, it won't stop the validation.
var errors = {};
if (newattrs.start_date === null) {
errors.start_date = "The course must have an assigned start date.";
}
if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) {
errors.end_date = "The course end date cannot be before the course start date.";
}
if (newattrs.start_date && newattrs.enrollment_start && newattrs.start_date < newattrs.enrollment_start) {
errors.enrollment_start = "The course start date cannot be before the enrollment start date.";
}
if (newattrs.enrollment_start && newattrs.enrollment_end && newattrs.enrollment_start >= newattrs.enrollment_end) {
errors.enrollment_end = "The enrollment start date cannot be after the enrollment end date.";
}
if (newattrs.end_date && newattrs.enrollment_end && newattrs.end_date < newattrs.enrollment_end) {
errors.enrollment_end = "The enrollment end date cannot be after the course end date.";
}
if (newattrs.intro_video && newattrs.intro_video !== this.get('intro_video')) {
if (this._videokey_illegal_chars.exec(newattrs.intro_video)) {
errors.intro_video = "Key should only contain letters, numbers, _, or -";
}
// TODO check if key points to a real video using google's youtube api
}
if (!_.isEmpty(errors)) return errors;
// NOTE don't return empty errors as that will be interpreted as an error state
},
_videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
save_videosource: function(newsource) {
// newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string
// returns the videosource for the preview which iss the key whose speed is closest to 1
if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.save({'intro_video': null});
// TODO remove all whitespace w/in string
else {
if (this.get('intro_video') !== newsource) this.save('intro_video', newsource);
}
return this.videosourceSample();
},
videosourceSample : function() {
if (this.has('intro_video')) return "http://www.youtube.com/embed/" + this.get('intro_video');
else return "";
}
});

View File

@@ -1,55 +1,56 @@
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({
defaults : {
course_location : null,
graders : null, // CourseGraderCollection
grade_cutoffs : null, // CourseGradeCutoff model
defaults : {
course_location : null,
graders : null, // CourseGraderCollection
grade_cutoffs : null, // CourseGradeCutoff model
grace_period : null // either null or { hours: n, minutes: m, ...}
},
parse: function(attributes) {
if (attributes['course_location']) {
attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true});
}
if (attributes['graders']) {
var graderCollection;
if (this.has('graders')) {
graderCollection = this.get('graders');
graderCollection.reset(attributes.graders);
}
else {
graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders);
graderCollection.course_location = attributes['course_location'] || this.get('course_location');
}
attributes.graders = graderCollection;
}
return attributes;
},
url : function() {
var location = this.get('course_location');
return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading';
},
gracePeriodToDate : function() {
var newDate = new Date();
if (this.has('grace_period') && this.get('grace_period')['hours'])
newDate.setHours(this.get('grace_period')['hours']);
else newDate.setHours(0);
if (this.has('grace_period') && this.get('grace_period')['minutes'])
newDate.setMinutes(this.get('grace_period')['minutes']);
else newDate.setMinutes(0);
if (this.has('grace_period') && this.get('grace_period')['seconds'])
newDate.setSeconds(this.get('grace_period')['seconds']);
else newDate.setSeconds(0);
return newDate;
},
dateToGracePeriod : function(date) {
return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() };
}
},
parse: function(attributes) {
if (attributes['course_location']) {
attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true});
}
if (attributes['graders']) {
var graderCollection;
// interesting race condition: if {parse:true} when newing, then parse called before .attributes created
if (this.attributes && this.has('graders')) {
graderCollection = this.get('graders');
graderCollection.reset(attributes.graders);
}
else {
graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders);
graderCollection.course_location = attributes['course_location'] || this.get('course_location');
}
attributes.graders = graderCollection;
}
return attributes;
},
url : function() {
var location = this.get('course_location');
return '/' + location.get('org') + "/" + location.get('course') + '/settings-details/' + location.get('name') + '/section/grading';
},
gracePeriodToDate : function() {
var newDate = new Date();
if (this.has('grace_period') && this.get('grace_period')['hours'])
newDate.setHours(this.get('grace_period')['hours']);
else newDate.setHours(0);
if (this.has('grace_period') && this.get('grace_period')['minutes'])
newDate.setMinutes(this.get('grace_period')['minutes']);
else newDate.setMinutes(0);
if (this.has('grace_period') && this.get('grace_period')['seconds'])
newDate.setSeconds(this.get('grace_period')['seconds']);
else newDate.setSeconds(0);
return newDate;
},
dateToGracePeriod : function(date) {
return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() };
}
});
CMS.Models.Settings.CourseGrader = Backbone.Model.extend({
defaults: {
defaults: {
"type" : "", // must be unique w/in collection (ie. w/in course)
"min_count" : 1,
"drop_count" : 0,
@@ -57,71 +58,71 @@ CMS.Models.Settings.CourseGrader = Backbone.Model.extend({
"weight" : 0 // int 0..100
},
parse : function(attrs) {
if (attrs['weight']) {
if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight);
}
if (attrs['min_count']) {
if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count);
}
if (attrs['drop_count']) {
if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count);
}
return attrs;
if (attrs['weight']) {
if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight);
}
if (attrs['min_count']) {
if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count);
}
if (attrs['drop_count']) {
if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count);
}
return attrs;
},
validate : function(attrs) {
var errors = {};
if (attrs['type']) {
if (_.isEmpty(attrs['type'])) {
errors.type = "The assignment type must have a name.";
}
else {
// FIXME somehow this.collection is unbound sometimes. I can't track down when
var existing = this.collection && this.collection.some(function(other) { return (other != this) && (other.get('type') == attrs['type']);}, this);
if (existing) {
errors.type = "There's already another assignment type with this name.";
}
}
}
if (attrs['weight']) {
if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) {
errors.weight = "Please enter an integer between 0 and 100.";
}
else {
attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int
if (this.collection && attrs.weight > 0) {
// FIXME b/c saves don't update the models if validation fails, we should
// either revert the field value to the one in the model and make them make room
// or figure out a wholistic way to balance the vals across the whole
// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100)
// errors.weight = "The weights cannot add to more than 100.";
}
}}
if (attrs['min_count']) {
if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) {
errors.min_count = "Please enter an integer.";
}
else attrs.min_count = parseInt(attrs.min_count);
}
if (attrs['drop_count']) {
if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) {
errors.drop_count = "Please enter an integer.";
}
else attrs.drop_count = parseInt(attrs.drop_count);
}
if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) {
errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned.";
}
if (!_.isEmpty(errors)) return errors;
var errors = {};
if (attrs['type']) {
if (_.isEmpty(attrs['type'])) {
errors.type = "The assignment type must have a name.";
}
else {
// FIXME somehow this.collection is unbound sometimes. I can't track down when
var existing = this.collection && this.collection.some(function(other) { return (other != this) && (other.get('type') == attrs['type']);}, this);
if (existing) {
errors.type = "There's already another assignment type with this name.";
}
}
}
if (attrs['weight']) {
if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) {
errors.weight = "Please enter an integer between 0 and 100.";
}
else {
attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int
if (this.collection && attrs.weight > 0) {
// FIXME b/c saves don't update the models if validation fails, we should
// either revert the field value to the one in the model and make them make room
// or figure out a wholistic way to balance the vals across the whole
// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100)
// errors.weight = "The weights cannot add to more than 100.";
}
}}
if (attrs['min_count']) {
if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) {
errors.min_count = "Please enter an integer.";
}
else attrs.min_count = parseInt(attrs.min_count);
}
if (attrs['drop_count']) {
if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) {
errors.drop_count = "Please enter an integer.";
}
else attrs.drop_count = parseInt(attrs.drop_count);
}
if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) {
errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned.";
}
if (!_.isEmpty(errors)) return errors;
}
});
CMS.Models.Settings.CourseGraderCollection = Backbone.Collection.extend({
model : CMS.Models.Settings.CourseGrader,
course_location : null, // must be set to a Location object
url : function() {
return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/';
},
sumWeights : function() {
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
}
model : CMS.Models.Settings.CourseGrader,
course_location : null, // must be set to a Location object
url : function() {
return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/settings-grading/' + this.course_location.get('name') + '/';
},
sumWeights : function() {
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
}
});

View File

@@ -1,43 +0,0 @@
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
CMS.Models.Settings.CourseSettings = Backbone.Model.extend({
// a container for the models representing the n possible tabbed states
defaults: {
courseLocation: null,
// NOTE: keep these sync'd w/ the data-section names in settings-page-menu
details: null,
faculty: null,
grading: null,
problems: null,
discussions: null
},
retrieve: function(submodel, callback) {
if (this.get(submodel)) callback();
else {
var cachethis = this;
switch (submodel) {
case 'details':
var details = new CMS.Models.Settings.CourseDetails({location: this.get('courseLocation')});
details.fetch( {
success : function(model) {
cachethis.set('details', model);
callback(model);
}
});
break;
case 'grading':
var grading = new CMS.Models.Settings.CourseGradingPolicy({course_location: this.get('courseLocation')});
grading.fetch( {
success : function(model) {
cachethis.set('grading', model);
callback(model);
}
});
break;
default:
break;
}
}
}
})

View File

@@ -1,78 +1,79 @@
// <!-- from https://github.com/Gazler/Underscore-Template-Loader/blob/master/index.html -->
// TODO Figure out how to initialize w/ static views from server (don't call .load but instead inject in django as strings)
// so this only loads the lazily loaded ones.
(function() {
if (typeof window.templateLoader == 'function') return;
var templateLoader = {
templateVersion: "0.0.12",
templates: {},
loadRemoteTemplate: function(templateName, filename, callback) {
if (!this.templates[templateName]) {
var self = this;
jQuery.ajax({url : filename,
success : function(data) {
self.addTemplate(templateName, data);
self.saveLocalTemplates();
callback(data);
},
error : function(xhdr, textStatus, errorThrown) {
console.log(textStatus); },
dataType : "html"
})
}
else {
callback(this.templates[templateName]);
}
},
addTemplate: function(templateName, data) {
// is there a reason this doesn't go ahead and compile the template? _.template(data)
// I suppose localstorage use would still req raw string rather than compiled version, but that sd work
// if it maintains a separate cache of uncompiled ones
this.templates[templateName] = data;
},
localStorageAvailable: function() {
try {
// window.cachetemplates is global set in base.js to turn caching on/off
return window.cachetemplates && 'localStorage' in window && window['localStorage'] !== null;
} catch (e) {
return false;
}
},
saveLocalTemplates: function() {
if (this.localStorageAvailable) {
localStorage.setItem("templates", JSON.stringify(this.templates));
localStorage.setItem("templateVersion", this.templateVersion);
}
},
loadLocalTemplates: function() {
if (this.localStorageAvailable) {
var templateVersion = localStorage.getItem("templateVersion");
if (templateVersion && templateVersion == this.templateVersion) {
var templates = localStorage.getItem("templates");
if (templates) {
templates = JSON.parse(templates);
for (var x in templates) {
if (!this.templates[x]) {
this.addTemplate(x, templates[x]);
}
}
}
}
else {
localStorage.removeItem("templates");
localStorage.removeItem("templateVersion");
}
}
}
(function () {
if (typeof window.templateLoader == 'function') return;
var templateLoader = {
templateVersion: "0.0.15",
templates: {},
// Control whether template caching in local memory occurs. Caching screws up development but may
// be a good optimization in production (it works fairly well).
cacheTemplates: false,
loadRemoteTemplate: function (templateName, filename, callback) {
if (!this.templates[templateName]) {
var self = this;
jQuery.ajax({url: filename,
success: function (data) {
self.addTemplate(templateName, data);
self.saveLocalTemplates();
callback(data);
},
error: function (xhdr, textStatus, errorThrown) {
console.log(textStatus);
},
dataType: "html"
})
}
else {
callback(this.templates[templateName]);
}
},
addTemplate: function (templateName, data) {
// is there a reason this doesn't go ahead and compile the template? _.template(data)
// I suppose localstorage use would still req raw string rather than compiled version, but that sd work
// if it maintains a separate cache of uncompiled ones
this.templates[templateName] = data;
},
localStorageAvailable: function () {
try {
return this.cacheTemplates && 'localStorage' in window && window['localStorage'] !== null;
} catch (e) {
return false;
}
},
saveLocalTemplates: function () {
if (this.localStorageAvailable()) {
localStorage.setItem("templates", JSON.stringify(this.templates));
localStorage.setItem("templateVersion", this.templateVersion);
}
},
loadLocalTemplates: function () {
if (this.localStorageAvailable()) {
var templateVersion = localStorage.getItem("templateVersion");
if (templateVersion && templateVersion == this.templateVersion) {
var templates = localStorage.getItem("templates");
if (templates) {
templates = JSON.parse(templates);
for (var x in templates) {
if (!this.templates[x]) {
this.addTemplate(x, templates[x]);
}
}
}
}
else {
localStorage.removeItem("templates");
localStorage.removeItem("templateVersion");
}
}
}
};
templateLoader.loadLocalTemplates();
window.templateLoader = templateLoader;
})();
})();

View File

@@ -0,0 +1,89 @@
if (!CMS.Views['Checklists']) CMS.Views.Checklists = {};
CMS.Views.Checklists = Backbone.View.extend({
// takes CMS.Models.Checklists as model
events : {
'click .course-checklist .checklist-title' : "toggleChecklist",
'click .course-checklist .task input' : "toggleTask",
'click a[rel="external"]' : window.cmsLinkNewWindow
},
initialize : function() {
var self = this;
this.collection.fetch({
complete: function () {
window.templateLoader.loadRemoteTemplate("checklist",
"/static/client_templates/checklist.html",
function (raw_template) {
self.template = _.template(raw_template);
self.render();
}
);
},
error: CMS.ServerError
}
);
},
render: function() {
// catch potential outside call before template loaded
if (!this.template) return this;
this.$el.empty();
var self = this;
_.each(this.collection.models,
function(checklist, index) {
self.$el.append(self.renderTemplate(checklist, index));
});
return this;
},
renderTemplate: function (checklist, index) {
var checklistItems = checklist.attributes['items'];
var itemsChecked = 0;
_.each(checklistItems,
function(checklist) {
if (checklist['is_checked']) {
itemsChecked +=1;
}
});
var percentChecked = Math.round((itemsChecked/checklistItems.length)*100);
return this.template({
checklistIndex : index,
checklistShortDescription : checklist.attributes['short_description'],
items: checklistItems,
itemsChecked: itemsChecked,
percentChecked: percentChecked});
},
toggleChecklist : function(e) {
e.preventDefault();
$(e.target).closest('.course-checklist').toggleClass('is-collapsed');
},
toggleTask : function (e) {
var self = this;
var completed = 'is-completed';
var $checkbox = $(e.target);
var $task = $checkbox.closest('.task');
$task.toggleClass(completed);
var checklist_index = $checkbox.data('checklist');
var task_index = $checkbox.data('task');
var model = this.collection.at(checklist_index);
model.attributes.items[task_index].is_checked = $task.hasClass(completed);
model.save({},
{
success : function() {
var updatedTemplate = self.renderTemplate(model, checklist_index);
self.$el.find('#course-checklist'+checklist_index).first().replaceWith(updatedTemplate);
},
error : CMS.ServerError
});
}
});

View File

@@ -10,7 +10,7 @@ CMS.Views.CourseInfoEdit = Backbone.View.extend({
render: function() {
// instantiate the ClassInfoUpdateView and delegate the proper dom to it
new CMS.Views.ClassInfoUpdateView({
el: this.$('#course-update-view'),
el: $('body.updates'),
collection: this.model.get('updates')
});
@@ -27,10 +27,10 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
// collection is CourseUpdateCollection
events: {
"click .new-update-button" : "onNew",
"click .save-button" : "onSave",
"click .cancel-button" : "onCancel",
"click .edit-button" : "onEdit",
"click .delete-button" : "onDelete"
"click #course-update-view .save-button" : "onSave",
"click #course-update-view .cancel-button" : "onCancel",
"click .post-actions > .edit-button" : "onEdit",
"click .post-actions > .delete-button" : "onDelete"
},
initialize: function() {
@@ -44,6 +44,8 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
self.render();
}
);
// when the client refetches the updates as a whole, re-render them
this.listenTo(this.collection, 'reset', this.render);
},
render: function () {
@@ -53,8 +55,12 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
$(updateEle).empty();
var self = this;
this.collection.each(function (update) {
var newEle = self.template({ updateModel : update });
$(updateEle).append(newEle);
try {
var newEle = self.template({ updateModel : update });
$(updateEle).append(newEle);
} catch (e) {
// ignore
}
});
this.$el.find(".new-update-form").hide();
this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' });
@@ -136,8 +142,11 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
onDelete: function(event) {
event.preventDefault();
// TODO ask for confirmation
// remove the dom element and delete the model
if (!confirm('Are you sure you want to delete this update? This action cannot be undone.')) {
return;
}
var targetModel = this.eventModel(event);
this.modelDom(event).remove();
var cacheThis = this;
@@ -150,7 +159,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
},
closeEditor: function(self, removePost) {
var targetModel = self.collection.getByCid(self.$currentPost.attr('name'));
var targetModel = self.collection.get(self.$currentPost.attr('name'));
if(removePost) {
self.$currentPost.remove();
@@ -160,8 +169,13 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
self.$currentPost.removeClass('editing');
self.$currentPost.find('.date-display').html(targetModel.get('date'));
self.$currentPost.find('.date').val(targetModel.get('date'));
self.$currentPost.find('.update-contents').html(targetModel.get('content'));
self.$currentPost.find('.new-update-content').val(targetModel.get('content'));
try {
// just in case the content causes an error (embedded js errors)
self.$currentPost.find('.update-contents').html(targetModel.get('content'));
self.$currentPost.find('.new-update-content').val(targetModel.get('content'));
} catch (e) {
// ignore but handle rest of page
}
self.$currentPost.find('form').hide();
window.$modalCover.unbind('click');
window.$modalCover.hide();
@@ -172,7 +186,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
// Dereferencing from events to screen elements
eventModel: function(event) {
// not sure if it should be currentTarget or delegateTarget
return this.collection.getByCid($(event.currentTarget).attr("name"));
return this.collection.get($(event.currentTarget).attr("name"));
},
modelDom: function(event) {

View File

@@ -0,0 +1,168 @@
if (!CMS.Views['Settings']) CMS.Views.Settings = {};
CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
error_saving : "error_saving",
successful_changes: "successful_changes",
// Model class is CMS.Models.Settings.Advanced
events : {
'focus :input' : "focusInput",
'blur :input' : "blurInput"
// TODO enable/disable save based on validation (currently enabled whenever there are changes)
},
initialize : function() {
var self = this;
// instantiates an editor template for each update in the collection
window.templateLoader.loadRemoteTemplate("advanced_entry",
"/static/client_templates/advanced_entry.html",
function (raw_template) {
self.template = _.template(raw_template);
self.render();
}
);
// because these are outside of this.$el, they can't be in the event hash
$('.save-button').on('click', this, this.saveView);
$('.cancel-button').on('click', this, this.revertView);
this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
},
render: function() {
// catch potential outside call before template loaded
if (!this.template) return this;
var listEle$ = this.$el.find('.course-advanced-policy-list');
listEle$.empty();
// b/c we've deleted all old fields, clear the map and repopulate
this.fieldToSelectorMap = {};
this.selectorToField = {};
// iterate through model and produce key : value editors for each property in model.get
var self = this;
_.each(_.sortBy(_.keys(this.model.attributes), _.identity),
function(key) {
listEle$.append(self.renderTemplate(key, self.model.get(key)));
});
var policyValues = listEle$.find('.json');
_.each(policyValues, this.attachJSONEditor, this);
this.showMessage();
return this;
},
attachJSONEditor : function (textarea) {
// Since we are allowing duplicate keys at the moment, it is possible that we will try to attach
// JSON Editor to a value that already has one. Therefore only attach if no CodeMirror peer exists.
if ( $(textarea).siblings().hasClass('CodeMirror')) {
return;
}
var self = this;
var oldValue = $(textarea).val();
CodeMirror.fromTextArea(textarea, {
mode: "application/json", lineNumbers: false, lineWrapping: false,
onChange: function(instance, changeobj) {
// this event's being called even when there's no change :-(
if (instance.getValue() !== oldValue) self.showSaveCancelButtons();
},
onFocus : function(mirror) {
$(textarea).parent().children('label').addClass("is-focused");
},
onBlur: function (mirror) {
$(textarea).parent().children('label').removeClass("is-focused");
var key = $(mirror.getWrapperElement()).closest('.field-group').children('.key').attr('id');
var stringValue = $.trim(mirror.getValue());
// update CodeMirror to show the trimmed value.
mirror.setValue(stringValue);
var JSONValue = undefined;
try {
JSONValue = JSON.parse(stringValue);
} catch (e) {
// If it didn't parse, try converting non-arrays/non-objects to a String.
// But don't convert single-quote strings, which are most likely errors.
var firstNonWhite = stringValue.substring(0, 1);
if (firstNonWhite !== "{" && firstNonWhite !== "[" && firstNonWhite !== "'") {
try {
stringValue = '"'+stringValue +'"';
JSONValue = JSON.parse(stringValue);
mirror.setValue(stringValue);
} catch(quotedE) {
// TODO: validation error
// console.log("Error with JSON, even after converting to String.");
// console.log(quotedE);
JSONValue = undefined;
}
}
}
if (JSONValue !== undefined) {
self.clearValidationErrors();
self.model.set(key, JSONValue, {validate: true});
}
}
});
},
showMessage: function (type) {
this.$el.find(".message-status").removeClass("is-shown");
if (type) {
if (type === this.error_saving) {
this.$el.find(".message-status.error").addClass("is-shown");
}
else if (type === this.successful_changes) {
this.$el.find(".message-status.confirm").addClass("is-shown");
this.hideSaveCancelButtons();
}
}
else {
// This is the case of the page first rendering, or when Cancel is pressed.
this.hideSaveCancelButtons();
}
},
showSaveCancelButtons: function(event) {
if (!this.buttonsVisible) {
this.$el.find(".message-status").removeClass("is-shown");
$('.wrapper-notification').addClass('is-shown');
this.buttonsVisible = true;
}
},
hideSaveCancelButtons: function() {
$('.wrapper-notification').removeClass('is-shown');
this.buttonsVisible = false;
},
saveView : function(event) {
// TODO one last verification scan:
// call validateKey on each to ensure proper format
// check for dupes
var self = event.data;
self.model.save({},
{
success : function() {
self.render();
self.showMessage(self.successful_changes);
},
error : CMS.ServerError
});
},
revertView : function(event) {
var self = event.data;
self.model.deleteKeys = [];
self.model.clear({silent : true});
self.model.fetch({
success : function() { self.render(); },
error : CMS.ServerError
});
},
renderTemplate: function (key, value) {
var newKeyId = _.uniqueId('policy_key_'),
newEle = this.template({ key : key, value : JSON.stringify(value, null, 4),
keyUniqueId: newKeyId, valueUniqueId: _.uniqueId('policy_value_')});
this.fieldToSelectorMap[key] = newKeyId;
this.selectorToField[newKeyId] = key;
return newEle;
},
focusInput : function(event) {
$(event.target).prev().addClass("is-focused");
},
blurInput : function(event) {
$(event.target).prev().removeClass("is-focused");
}
});

View File

@@ -1,222 +1,92 @@
if (!CMS.Views['Settings']) CMS.Views.Settings = {};
// TODO move to common place
CMS.Views.ValidatingView = Backbone.View.extend({
// Intended as an abstract class which catches validation errors on the model and
// decorates the fields. Needs wiring per class, but this initialization shows how
// either have your init call this one or copy the contents
initialize : function() {
this.model.on('error', this.handleValidationError, this);
this.selectorToField = _.invert(this.fieldToSelectorMap);
},
errorTemplate : _.template('<span class="message-error"><%= message %></span>'),
events : {
"blur input" : "clearValidationErrors",
"blur textarea" : "clearValidationErrors"
},
fieldToSelectorMap : {
// Your subclass must populate this w/ all of the model keys and dom selectors
// which may be the subjects of validation errors
},
_cacheValidationErrors : [],
handleValidationError : function(model, error) {
// error is object w/ fields and error strings
for (var field in error) {
var ele = this.$el.find('#' + this.fieldToSelectorMap[field]);
this._cacheValidationErrors.push(ele);
if ($(ele).is('div')) {
// put error on the contained inputs
$(ele).find('input, textarea').addClass('error');
}
else $(ele).addClass('error');
$(ele).parent().append(this.errorTemplate({message : error[field]}));
}
},
clearValidationErrors : function() {
// error is object w/ fields and error strings
while (this._cacheValidationErrors.length > 0) {
var ele = this._cacheValidationErrors.pop();
if ($(ele).is('div')) {
// put error on the contained inputs
$(ele).find('input, textarea').removeClass('error');
}
else $(ele).removeClass('error');
$(ele).nextAll('.message-error').remove();
}
},
saveIfChanged : function(event) {
// returns true if the value changed and was thus sent to server
var field = this.selectorToField[event.currentTarget.id];
var currentVal = this.model.get(field);
var newVal = $(event.currentTarget).val();
if (currentVal != newVal) {
this.clearValidationErrors();
this.model.save(field, newVal, { error : CMS.ServerError});
return true;
}
else return false;
}
});
CMS.Views.Settings.Main = Backbone.View.extend({
// Model class is CMS.Models.Settings.CourseSettings
// allow navigation between the tabs
events: {
'click .settings-page-menu a': "showSettingsTab",
'mouseover #timezone' : "updateTime"
},
currentTab: null,
subviews: {}, // indexed by tab name
initialize: function() {
// load templates
this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section');
// create the initial subview
this.subviews[this.currentTab] = this.createSubview();
// fill in fields
this.$el.find("#course-name").val(this.model.get('courseLocation').get('name'));
this.$el.find("#course-organization").val(this.model.get('courseLocation').get('org'));
this.$el.find("#course-number").val(this.model.get('courseLocation').get('course'));
this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
this.$el.find(":input, textarea").focus(function() {
$("label[for='" + this.id + "']").addClass("is-focused");
}).blur(function() {
$("label").removeClass("is-focused");
});
this.render();
},
render: function() {
// create any necessary subviews and put them onto the page
if (!this.model.has(this.currentTab)) {
// TODO disable screen until fetch completes?
var cachethis = this;
this.model.retrieve(this.currentTab, function() {
cachethis.subviews[cachethis.currentTab] = cachethis.createSubview();
cachethis.subviews[cachethis.currentTab].render();
});
}
else this.subviews[this.currentTab].render();
var dateIntrospect = new Date();
this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")");
return this;
},
createSubview: function() {
switch (this.currentTab) {
case 'details':
return new CMS.Views.Settings.Details({
el: this.$el.find('.settings-' + this.currentTab),
model: this.model.get(this.currentTab)
});
case 'faculty':
break;
case 'grading':
return new CMS.Views.Settings.Grading({
el: this.$el.find('.settings-' + this.currentTab),
model: this.model.get(this.currentTab)
});
case 'problems':
break;
case 'discussions':
break;
}
},
updateTime : function(e) {
var now = new Date();
var hours = now.getHours();
var minutes = now.getMinutes();
$(e.currentTarget).attr('title', (hours % 12 === 0 ? 12 : hours % 12) + ":" + (minutes < 10 ? "0" : "") +
now.getMinutes() + (hours < 12 ? "am" : "pm") + " (current local time)");
},
showSettingsTab: function(e) {
this.currentTab = $(e.target).attr('data-section');
$('.settings-page-section > section').hide();
$('.settings-' + this.currentTab).show();
$('.settings-page-menu .is-shown').removeClass('is-shown');
$(e.target).addClass('is-shown');
// fetch model for the tab if not loaded already
this.render();
}
});
CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseDetails
events : {
"blur input" : "updateModel",
"blur textarea" : "updateModel",
'click .remove-course-syllabus' : "removeSyllabus",
'click .new-course-syllabus' : 'assetSyllabus',
'click .remove-course-introduction-video' : "removeVideo",
'focus #course-overview' : "codeMirrorize"
},
initialize : function() {
// TODO move the html frag to a loaded asset
this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="ss-icon ss-standard">&#x1F4C4;</i><%= filename %></a>');
this.model.on('error', this.handleValidationError, this);
this.selectorToField = _.invert(this.fieldToSelectorMap);
},
render: function() {
this.setupDatePicker('start_date');
this.setupDatePicker('end_date');
this.setupDatePicker('enrollment_start');
this.setupDatePicker('enrollment_end');
if (this.model.has('syllabus')) {
this.$el.find(this.fieldToSelectorMap['syllabus']).html(
this.fileAnchorTemplate({
fullpath : this.model.get('syllabus'),
filename: 'syllabus'}));
this.$el.find('.remove-course-syllabus').show();
}
else {
this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html("");
this.$el.find('.remove-course-syllabus').hide();
}
this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview'));
this.codeMirrorize(null, $('#course-overview')[0]);
this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample());
if (this.model.has('intro_video')) {
this.$el.find('.remove-course-introduction-video').show();
this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video'));
}
else this.$el.find('.remove-course-introduction-video').hide();
this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort'));
return this;
},
fieldToSelectorMap : {
'start_date' : "course-start",
'end_date' : 'course-end',
'enrollment_start' : 'enrollment-start',
'enrollment_end' : 'enrollment-end',
'syllabus' : '.current-course-syllabus .doc-filename',
'overview' : 'course-overview',
'intro_video' : 'course-introduction-video',
'effort' : "course-effort"
},
// Model class is CMS.Models.Settings.CourseDetails
events : {
"change input" : "updateModel",
"change textarea" : "updateModel",
'click .remove-course-syllabus' : "removeSyllabus",
'click .new-course-syllabus' : 'assetSyllabus',
'click .remove-course-introduction-video' : "removeVideo",
'focus #course-overview' : "codeMirrorize",
'mouseover #timezone' : "updateTime",
// would love to move to a general superclass, but event hashes don't inherit in backbone :-(
'focus :input' : "inputFocus",
'blur :input' : "inputUnfocus"
},
initialize : function() {
this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="ss-icon ss-standard">&#x1F4C4;</i><%= filename %></a>');
// fill in fields
this.$el.find("#course-name").val(this.model.get('location').get('name'));
this.$el.find("#course-organization").val(this.model.get('location').get('org'));
this.$el.find("#course-number").val(this.model.get('location').get('course'));
this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
var dateIntrospect = new Date();
this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")");
this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.selectorToField = _.invert(this.fieldToSelectorMap);
},
render: function() {
this.setupDatePicker('start_date');
this.setupDatePicker('end_date');
this.setupDatePicker('enrollment_start');
this.setupDatePicker('enrollment_end');
if (this.model.has('syllabus')) {
this.$el.find(this.fieldToSelectorMap['syllabus']).html(
this.fileAnchorTemplate({
fullpath : this.model.get('syllabus'),
filename: 'syllabus'}));
this.$el.find('.remove-course-syllabus').show();
}
else {
this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html("");
this.$el.find('.remove-course-syllabus').hide();
}
this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview'));
this.codeMirrorize(null, $('#course-overview')[0]);
this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample());
if (this.model.has('intro_video')) {
this.$el.find('.remove-course-introduction-video').show();
this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video'));
}
else this.$el.find('.remove-course-introduction-video').hide();
this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort'));
return this;
},
fieldToSelectorMap : {
'start_date' : "course-start",
'end_date' : 'course-end',
'enrollment_start' : 'enrollment-start',
'enrollment_end' : 'enrollment-end',
'syllabus' : '.current-course-syllabus .doc-filename',
'overview' : 'course-overview',
'intro_video' : 'course-introduction-video',
'effort' : "course-effort"
},
updateTime : function(e) {
var now = new Date();
var hours = now.getHours();
var minutes = now.getMinutes();
$(e.currentTarget).attr('title', (hours % 12 === 0 ? 12 : hours % 12) + ":" + (minutes < 10 ? "0" : "") +
now.getMinutes() + (hours < 12 ? "am" : "pm") + " (current local time)");
},
setupDatePicker: function (fieldName) {
var cacheModel = this.model;
var div = this.$el.find('#' + this.fieldToSelectorMap[fieldName]);
var datefield = $(div).find(".date");
var timefield = $(div).find(".time");
var datefield = $(div).find("input:.date");
var timefield = $(div).find("input:.time");
var cachethis = this;
var savefield = function () {
cachethis.clearValidationErrors();
@@ -228,9 +98,15 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
}
var newVal = new Date(date.getTime() + time * 1000);
if (!cacheModel.has(fieldName) || cacheModel.get(fieldName).getTime() !== newVal.getTime()) {
cacheModel.save(fieldName, newVal, { error: CMS.ServerError});
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
@@ -245,58 +121,57 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
datefield.datepicker('setDate', this.model.get(fieldName));
if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName));
},
updateModel: function(event) {
switch (event.currentTarget.id) {
case 'course-start-date': // handled via onSelect method
case 'course-end-date':
case 'course-enrollment-start-date':
case 'course-enrollment-end-date':
break;
case 'course-overview':
// handled via code mirror
break;
updateModel: function(event) {
switch (event.currentTarget.id) {
case 'course-start-date': // handled via onSelect method
case 'course-end-date':
case 'course-enrollment-start-date':
case 'course-enrollment-end-date':
break;
case 'course-effort':
this.saveIfChanged(event);
break;
case 'course-introduction-video':
this.clearValidationErrors();
var previewsource = this.model.save_videosource($(event.currentTarget).val());
this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource);
if (this.model.has('intro_video')) {
this.$el.find('.remove-course-introduction-video').show();
}
else {
this.$el.find('.remove-course-introduction-video').hide();
}
break;
default:
break;
}
},
removeSyllabus: function() {
if (this.model.has('syllabus')) this.model.save({'syllabus': null},
{ error : CMS.ServerError});
},
assetSyllabus : function() {
// TODO implement
},
removeVideo: function() {
if (this.model.has('intro_video')) {
this.model.save_videosource(null);
this.$el.find(".current-course-introduction-video iframe").attr("src", "");
this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val("");
this.$el.find('.remove-course-introduction-video').hide();
}
},
codeMirrors : {},
case 'course-overview':
// handled via code mirror
break;
case 'course-effort':
this.saveIfChanged(event);
break;
case 'course-introduction-video':
this.clearValidationErrors();
var previewsource = this.model.save_videosource($(event.currentTarget).val());
this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource);
if (this.model.has('intro_video')) {
this.$el.find('.remove-course-introduction-video').show();
}
else {
this.$el.find('.remove-course-introduction-video').hide();
}
break;
default:
break;
}
},
removeSyllabus: function() {
if (this.model.has('syllabus')) this.model.save({'syllabus': null});
},
assetSyllabus : function() {
// TODO implement
},
removeVideo: function() {
if (this.model.has('intro_video')) {
this.model.save_videosource(null);
this.$el.find(".current-course-introduction-video iframe").attr("src", "");
this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val("");
this.$el.find('.remove-course-introduction-video').hide();
}
},
codeMirrors : {},
codeMirrorize: function (e, forcedTarget) {
var thisTarget;
if (forcedTarget) {
@@ -315,374 +190,11 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
mirror.save();
cachethis.clearValidationErrors();
var newVal = mirror.getValue();
if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal,
{ error: CMS.ServerError});
if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal);
}
});
}
}
});
CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseGradingPolicy
events : {
"blur input" : "updateModel",
"blur textarea" : "updateModel",
"blur span[contenteditable=true]" : "updateDesignation",
"click .settings-extra header" : "showSettingsExtras",
"click .new-grade-button" : "addNewGrade",
"click .remove-button" : "removeGrade",
"click .add-grading-data" : "addAssignmentType"
},
initialize : function() {
// load template for grading view
var self = this;
this.gradeCutoffTemplate = _.template('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable>' +
'<%= descriptor %>' +
'</span><span class="range"></span>' +
'<% if (removable) {%><a href="#" class="remove-button">remove</a><% ;} %>' +
'</li>');
// Instrument grading scale
// convert cutoffs to inversely ordered list
var modelCutoffs = this.model.get('grade_cutoffs');
for (var cutoff in modelCutoffs) {
this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)});
}
this.descendingCutoffs = _.sortBy(this.descendingCutoffs,
function (gradeEle) { return -gradeEle['cutoff']; });
// Instrument grace period
this.$el.find('#course-grading-graceperiod').timepicker();
// instantiates an editor template for each update in the collection
// Because this calls render, put it after everything which render may depend upon to prevent race condition.
window.templateLoader.loadRemoteTemplate("course_grade_policy",
"/static/client_templates/course_grade_policy.html",
function (raw_template) {
self.template = _.template(raw_template);
self.render();
}
);
this.model.on('error', this.handleValidationError, this);
this.model.get('graders').on('remove', this.render, this);
this.model.get('graders').on('reset', this.render, this);
this.model.get('graders').on('add', this.render, this);
this.selectorToField = _.invert(this.fieldToSelectorMap);
},
render: function() {
// prevent bootstrap race condition by event dispatch
if (!this.template) return;
// Create and render the grading type subs
var self = this;
var gradelist = this.$el.find('.course-grading-assignment-list');
// Undo the double invocation error. At some point, fix the double invocation
$(gradelist).empty();
var gradeCollection = this.model.get('graders');
gradeCollection.each(function(gradeModel) {
$(gradelist).append(self.template({model : gradeModel }));
var newEle = gradelist.children().last();
var newView = new CMS.Views.Settings.GraderView({el: newEle,
model : gradeModel, collection : gradeCollection });
});
// render the grade cutoffs
this.renderCutoffBar();
var graceEle = this.$el.find('#course-grading-graceperiod');
graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime
if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate());
// remove any existing listeners to keep them from piling on b/c render gets called frequently
graceEle.off('change', this.setGracePeriod);
graceEle.on('change', this, this.setGracePeriod);
return this;
},
addAssignmentType : function(e) {
e.preventDefault();
this.model.get('graders').push({});
},
fieldToSelectorMap : {
'grace_period' : 'course-grading-graceperiod'
},
setGracePeriod : function(event) {
event.data.clearValidationErrors();
var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal,
{ error : CMS.ServerError});
},
updateModel : function(event) {
if (!this.selectorToField[event.currentTarget.id]) return;
switch (this.selectorToField[event.currentTarget.id]) {
case 'grace_period': // handled above
break;
default:
this.saveIfChanged(event);
break;
}
},
// Grade sliders attributes and methods
// Grade bars are li's ordered A -> F with A taking whole width, B overlaying it with its paint, ...
// The actual cutoff for each grade is the width % of the next lower grade; so, the hack here
// is to lay down a whole width bar claiming it's A and then lay down bars for each actual grade
// starting w/ A but posting the label in the preceding li and setting the label of the last to "Fail" or "F"
// A does not have a drag bar (cannot change its upper limit)
// Need to insert new bars in right place.
GRADES : ['A', 'B', 'C', 'D'], // defaults for new grade designators
descendingCutoffs : [], // array of { designation : , cutoff : }
gradeBarWidth : null, // cache of value since it won't change (more certain)
renderCutoffBar: function() {
var gradeBar =this.$el.find('.grade-bar');
this.gradeBarWidth = gradeBar.width();
var gradelist = gradeBar.children('.grades');
// HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x
gradelist.empty();
var nextWidth = 100; // first width is 100%
// Can probably be simplified to one variable now.
var removable = false;
var draggable = false; // first and last are not removable, first is not draggable
_.each(this.descendingCutoffs,
function(cutoff, index) {
var newBar = this.gradeCutoffTemplate({
descriptor : cutoff['designation'] ,
width : nextWidth,
removable : removable });
gradelist.append(newBar);
if (draggable) {
newBar = gradelist.children().last(); // get the dom object not the unparsed string
newBar.resizable({
handles: "e",
containment : "parent",
start : this.startMoveClosure(),
resize : this.moveBarClosure(),
stop : this.stopDragClosure()
});
}
// prepare for next
nextWidth = cutoff['cutoff'];
removable = true; // first is not removable, all others are
draggable = true;
},
this);
// add fail which is not in data
var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(),
width : nextWidth, removable : false});
$(failBar).find("span[contenteditable=true]").attr("contenteditable", false);
gradelist.append(failBar);
gradelist.children().last().resizable({
handles: "e",
containment : "parent",
start : this.startMoveClosure(),
resize : this.moveBarClosure(),
stop : this.stopDragClosure()
});
this.renderGradeRanges();
},
showSettingsExtras : function(event) {
$(event.currentTarget).toggleClass('active');
$(event.currentTarget).siblings.toggleClass('is-shown');
},
startMoveClosure : function() {
// set min/max widths
var cachethis = this;
var widthPerPoint = cachethis.gradeBarWidth / 100;
return function(event, ui) {
var barIndex = ui.element.index();
// min and max represent limits not labels (note, can's make smaller than 3 points wide)
var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3);
// minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it
var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 97);
ui.element.resizable("option",{minWidth : min * widthPerPoint, maxWidth : max * widthPerPoint});
};
},
moveBarClosure : function() {
// 0th ele doesn't have a bar; so, will never invoke this
var cachethis = this;
return function(event, ui) {
var barIndex = ui.element.index();
// min and max represent limits not labels (note, can's make smaller than 3 points wide)
var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3);
// minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it
var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 100);
var percentage = Math.min(Math.max(ui.size.width / cachethis.gradeBarWidth * 100, min), max);
cachethis.descendingCutoffs[barIndex - 1]['cutoff'] = Math.round(percentage);
cachethis.renderGradeRanges();
};
},
renderGradeRanges: function() {
// the labels showing the range e.g., 71-80
var cutoffs = this.descendingCutoffs;
this.$el.find('.range').each(function(i) {
var min = (i < cutoffs.length ? cutoffs[i]['cutoff'] : 0);
var max = (i > 0 ? cutoffs[i - 1]['cutoff'] : 100);
$(this).text(min + '-' + max);
});
},
stopDragClosure: function() {
var cachethis = this;
return function(event, ui) {
// for some reason the resize is setting height to 0
cachethis.saveCutoffs();
};
},
saveCutoffs: function() {
this.model.save('grade_cutoffs',
_.reduce(this.descendingCutoffs,
function(object, cutoff) {
object[cutoff['designation']] = cutoff['cutoff'] / 100.0;
return object;
},
{}),
{ error : CMS.ServerError});
},
addNewGrade: function(e) {
e.preventDefault();
var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades
if(gradeLength > 3) {
// TODO shouldn't we disable the button
return;
}
var failBarWidth = this.descendingCutoffs[gradeLength - 1]['cutoff'];
// going to split the grade above the insertion point in half leaving fail in same place
var nextGradeTop = (gradeLength > 1 ? this.descendingCutoffs[gradeLength - 2]['cutoff'] : 100);
var targetWidth = failBarWidth + ((nextGradeTop - failBarWidth) / 2);
this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth});
this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth);
var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength],
width : targetWidth, removable : true });
var gradeDom = this.$el.find('.grades');
gradeDom.children().last().before($newGradeBar);
var newEle = gradeDom.children()[gradeLength];
$(newEle).resizable({
handles: "e",
containment : "parent",
start : this.startMoveClosure(),
resize : this.moveBarClosure(),
stop : this.stopDragClosure()
});
// Munge existing grade labels?
// If going from Pass/Fail to 3 levels, change to Pass to A
if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') {
this.descendingCutoffs[0]['designation'] = this.GRADES[0];
this.setTopGradeLabel();
}
this.setFailLabel();
this.renderGradeRanges();
this.saveCutoffs();
},
removeGrade: function(e) {
e.preventDefault();
var domElement = $(e.currentTarget).closest('li');
var index = domElement.index();
// copy the boundary up to the next higher grade then remove
this.descendingCutoffs[index - 1]['cutoff'] = this.descendingCutoffs[index]['cutoff'];
this.descendingCutoffs.splice(index, 1);
domElement.remove();
if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) {
this.descendingCutoffs[0]['designation'] = 'Pass';
this.setTopGradeLabel();
}
this.setFailLabel();
this.renderGradeRanges();
this.saveCutoffs();
},
updateDesignation: function(e) {
var index = $(e.currentTarget).closest('li').index();
this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html();
this.saveCutoffs();
},
failLabel: function() {
if (this.descendingCutoffs.length === 1) return 'Fail';
else return 'F';
},
setFailLabel: function() {
this.$el.find('.grades .letter-grade').last().html(this.failLabel());
},
setTopGradeLabel: function() {
this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']);
}
});
CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseGrader
events : {
"blur input" : "updateModel",
"blur textarea" : "updateModel",
"click .remove-grading-data" : "deleteModel"
},
initialize : function() {
this.model.on('error', this.handleValidationError, this);
this.selectorToField = _.invert(this.fieldToSelectorMap);
this.render();
},
render: function() {
return this;
},
fieldToSelectorMap : {
'type' : 'course-grading-assignment-name',
'short_label' : 'course-grading-assignment-shortname',
'min_count' : 'course-grading-assignment-totalassignments',
'drop_count' : 'course-grading-assignment-droppable',
'weight' : 'course-grading-assignment-gradeweight'
},
updateModel : function(event) {
// HACK to fix model sometimes losing its pointer to the collection [I think I fixed this but leaving
// this in out of paranoia. If this error ever happens, the user will get a warning that they cannot
// give 2 assignments the same name.]
if (!this.model.collection) {
this.model.collection = this.collection;
}
switch (event.currentTarget.id) {
case 'course-grading-assignment-totalassignments':
this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val());
this.saveIfChanged(event);
break;
case 'course-grading-assignment-name':
var oldName = this.model.get('type');
if (this.saveIfChanged(event) && !_.isEmpty(oldName)) {
// overload the error display logic
this._cacheValidationErrors.push(event.currentTarget);
$(event.currentTarget).parent().append(
this.errorTemplate({message : 'For grading to work, you must change all "' + oldName +
'" subsections to "' + this.model.get('type') + '".'}));
}
break;
default:
this.saveIfChanged(event);
break;
}
},
deleteModel : function(e) {
this.model.destroy(
{ error : CMS.ServerError});
e.preventDefault();
}
});

View File

@@ -0,0 +1,370 @@
if (!CMS.Views['Settings']) CMS.Views.Settings = {}; // ensure the pseudo pkg exists
CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseGradingPolicy
events : {
"change input" : "updateModel",
"change textarea" : "updateModel",
"change span[contenteditable=true]" : "updateDesignation",
"click .settings-extra header" : "showSettingsExtras",
"click .new-grade-button" : "addNewGrade",
"click .remove-button" : "removeGrade",
"click .add-grading-data" : "addAssignmentType",
// would love to move to a general superclass, but event hashes don't inherit in backbone :-(
'focus :input' : "inputFocus",
'blur :input' : "inputUnfocus"
},
initialize : function() {
// load template for grading view
var self = this;
this.gradeCutoffTemplate = _.template('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable>' +
'<%= descriptor %>' +
'</span><span class="range"></span>' +
'<% if (removable) {%><a href="#" class="remove-button">remove</a><% ;} %>' +
'</li>');
// Instrument grading scale
// convert cutoffs to inversely ordered list
var modelCutoffs = this.model.get('grade_cutoffs');
for (var cutoff in modelCutoffs) {
this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)});
}
this.descendingCutoffs = _.sortBy(this.descendingCutoffs,
function (gradeEle) { return -gradeEle['cutoff']; });
// Instrument grace period
this.$el.find('#course-grading-graceperiod').timepicker();
// instantiates an editor template for each update in the collection
// Because this calls render, put it after everything which render may depend upon to prevent race condition.
window.templateLoader.loadRemoteTemplate("course_grade_policy",
"/static/client_templates/course_grade_policy.html",
function (raw_template) {
self.template = _.template(raw_template);
self.render();
}
);
this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.model.get('graders').on('remove', this.render, this);
this.model.get('graders').on('reset', this.render, this);
this.model.get('graders').on('add', this.render, this);
this.selectorToField = _.invert(this.fieldToSelectorMap);
},
render: function() {
// prevent bootstrap race condition by event dispatch
if (!this.template) return;
// Create and render the grading type subs
var self = this;
var gradelist = this.$el.find('.course-grading-assignment-list');
// Undo the double invocation error. At some point, fix the double invocation
$(gradelist).empty();
var gradeCollection = this.model.get('graders');
gradeCollection.each(function(gradeModel) {
$(gradelist).append(self.template({model : gradeModel }));
var newEle = gradelist.children().last();
var newView = new CMS.Views.Settings.GraderView({el: newEle,
model : gradeModel, collection : gradeCollection });
});
// render the grade cutoffs
this.renderCutoffBar();
var graceEle = this.$el.find('#course-grading-graceperiod');
graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime
if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate());
// remove any existing listeners to keep them from piling on b/c render gets called frequently
graceEle.off('change', this.setGracePeriod);
graceEle.on('change', this, this.setGracePeriod);
return this;
},
addAssignmentType : function(e) {
e.preventDefault();
this.model.get('graders').push({});
},
fieldToSelectorMap : {
'grace_period' : 'course-grading-graceperiod'
},
setGracePeriod : function(event) {
event.data.clearValidationErrors();
var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal);
},
updateModel : function(event) {
if (!this.selectorToField[event.currentTarget.id]) return;
switch (this.selectorToField[event.currentTarget.id]) {
case 'grace_period': // handled above
break;
default:
this.saveIfChanged(event);
break;
}
},
// Grade sliders attributes and methods
// Grade bars are li's ordered A -> F with A taking whole width, B overlaying it with its paint, ...
// The actual cutoff for each grade is the width % of the next lower grade; so, the hack here
// is to lay down a whole width bar claiming it's A and then lay down bars for each actual grade
// starting w/ A but posting the label in the preceding li and setting the label of the last to "Fail" or "F"
// A does not have a drag bar (cannot change its upper limit)
// Need to insert new bars in right place.
GRADES : ['A', 'B', 'C', 'D'], // defaults for new grade designators
descendingCutoffs : [], // array of { designation : , cutoff : }
gradeBarWidth : null, // cache of value since it won't change (more certain)
renderCutoffBar: function() {
var gradeBar =this.$el.find('.grade-bar');
this.gradeBarWidth = gradeBar.width();
var gradelist = gradeBar.children('.grades');
// HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x
gradelist.empty();
var nextWidth = 100; // first width is 100%
// Can probably be simplified to one variable now.
var removable = false;
var draggable = false; // first and last are not removable, first is not draggable
_.each(this.descendingCutoffs,
function(cutoff, index) {
var newBar = this.gradeCutoffTemplate({
descriptor : cutoff['designation'] ,
width : nextWidth,
removable : removable });
gradelist.append(newBar);
if (draggable) {
newBar = gradelist.children().last(); // get the dom object not the unparsed string
newBar.resizable({
handles: "e",
containment : "parent",
start : this.startMoveClosure(),
resize : this.moveBarClosure(),
stop : this.stopDragClosure()
});
}
// prepare for next
nextWidth = cutoff['cutoff'];
removable = true; // first is not removable, all others are
draggable = true;
},
this);
// add fail which is not in data
var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(),
width : nextWidth, removable : false});
$(failBar).find("span[contenteditable=true]").attr("contenteditable", false);
gradelist.append(failBar);
gradelist.children().last().resizable({
handles: "e",
containment : "parent",
start : this.startMoveClosure(),
resize : this.moveBarClosure(),
stop : this.stopDragClosure()
});
this.renderGradeRanges();
},
showSettingsExtras : function(event) {
$(event.currentTarget).toggleClass('active');
$(event.currentTarget).siblings.toggleClass('is-shown');
},
startMoveClosure : function() {
// set min/max widths
var cachethis = this;
var widthPerPoint = cachethis.gradeBarWidth / 100;
return function(event, ui) {
var barIndex = ui.element.index();
// min and max represent limits not labels (note, can's make smaller than 3 points wide)
var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3);
// minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it
var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 97);
ui.element.resizable("option",{minWidth : min * widthPerPoint, maxWidth : max * widthPerPoint});
};
},
moveBarClosure : function() {
// 0th ele doesn't have a bar; so, will never invoke this
var cachethis = this;
return function(event, ui) {
var barIndex = ui.element.index();
// min and max represent limits not labels (note, can's make smaller than 3 points wide)
var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3);
// minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it
var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 100);
var percentage = Math.min(Math.max(ui.size.width / cachethis.gradeBarWidth * 100, min), max);
cachethis.descendingCutoffs[barIndex - 1]['cutoff'] = Math.round(percentage);
cachethis.renderGradeRanges();
};
},
renderGradeRanges: function() {
// the labels showing the range e.g., 71-80
var cutoffs = this.descendingCutoffs;
this.$el.find('.range').each(function(i) {
var min = (i < cutoffs.length ? cutoffs[i]['cutoff'] : 0);
var max = (i > 0 ? cutoffs[i - 1]['cutoff'] : 100);
$(this).text(min + '-' + max);
});
},
stopDragClosure: function() {
var cachethis = this;
return function(event, ui) {
// for some reason the resize is setting height to 0
cachethis.saveCutoffs();
};
},
saveCutoffs: function() {
this.model.save('grade_cutoffs',
_.reduce(this.descendingCutoffs,
function(object, cutoff) {
object[cutoff['designation']] = cutoff['cutoff'] / 100.0;
return object;
},
{}));
},
addNewGrade: function(e) {
e.preventDefault();
var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades
if(gradeLength > 3) {
// TODO shouldn't we disable the button
return;
}
var failBarWidth = this.descendingCutoffs[gradeLength - 1]['cutoff'];
// going to split the grade above the insertion point in half leaving fail in same place
var nextGradeTop = (gradeLength > 1 ? this.descendingCutoffs[gradeLength - 2]['cutoff'] : 100);
var targetWidth = failBarWidth + ((nextGradeTop - failBarWidth) / 2);
this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth});
this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth);
var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength],
width : targetWidth, removable : true });
var gradeDom = this.$el.find('.grades');
gradeDom.children().last().before($newGradeBar);
var newEle = gradeDom.children()[gradeLength];
$(newEle).resizable({
handles: "e",
containment : "parent",
start : this.startMoveClosure(),
resize : this.moveBarClosure(),
stop : this.stopDragClosure()
});
// Munge existing grade labels?
// If going from Pass/Fail to 3 levels, change to Pass to A
if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') {
this.descendingCutoffs[0]['designation'] = this.GRADES[0];
this.setTopGradeLabel();
}
this.setFailLabel();
this.renderGradeRanges();
this.saveCutoffs();
},
removeGrade: function(e) {
e.preventDefault();
var domElement = $(e.currentTarget).closest('li');
var index = domElement.index();
// copy the boundary up to the next higher grade then remove
this.descendingCutoffs[index - 1]['cutoff'] = this.descendingCutoffs[index]['cutoff'];
this.descendingCutoffs.splice(index, 1);
domElement.remove();
if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) {
this.descendingCutoffs[0]['designation'] = 'Pass';
this.setTopGradeLabel();
}
this.setFailLabel();
this.renderGradeRanges();
this.saveCutoffs();
},
updateDesignation: function(e) {
var index = $(e.currentTarget).closest('li').index();
this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html();
this.saveCutoffs();
},
failLabel: function() {
if (this.descendingCutoffs.length === 1) return 'Fail';
else return 'F';
},
setFailLabel: function() {
this.$el.find('.grades .letter-grade').last().html(this.failLabel());
},
setTopGradeLabel: function() {
this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']);
}
});
CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseGrader
events : {
"change input" : "updateModel",
"change textarea" : "updateModel",
"click .remove-grading-data" : "deleteModel",
// would love to move to a general superclass, but event hashes don't inherit in backbone :-(
'focus :input' : "inputFocus",
'blur :input' : "inputUnfocus"
},
initialize : function() {
this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.selectorToField = _.invert(this.fieldToSelectorMap);
this.render();
},
render: function() {
return this;
},
fieldToSelectorMap : {
'type' : 'course-grading-assignment-name',
'short_label' : 'course-grading-assignment-shortname',
'min_count' : 'course-grading-assignment-totalassignments',
'drop_count' : 'course-grading-assignment-droppable',
'weight' : 'course-grading-assignment-gradeweight'
},
updateModel : function(event) {
// HACK to fix model sometimes losing its pointer to the collection [I think I fixed this but leaving
// this in out of paranoia. If this error ever happens, the user will get a warning that they cannot
// give 2 assignments the same name.]
if (!this.model.collection) {
this.model.collection = this.collection;
}
switch (event.currentTarget.id) {
case 'course-grading-assignment-totalassignments':
this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val());
this.saveIfChanged(event);
break;
case 'course-grading-assignment-name':
var oldName = this.model.get('type');
if (this.saveIfChanged(event) && !_.isEmpty(oldName)) {
// overload the error display logic
this._cacheValidationErrors.push(event.currentTarget);
$(event.currentTarget).parent().append(
this.errorTemplate({message : 'For grading to work, you must change all "' + oldName +
'" subsections to "' + this.model.get('type') + '".'}));
}
break;
default:
this.saveIfChanged(event);
break;
}
},
deleteModel : function(e) {
this.model.destroy(
{ error : CMS.ServerError});
e.preventDefault();
}
});

View File

@@ -0,0 +1,72 @@
CMS.Views.ValidatingView = Backbone.View.extend({
// Intended as an abstract class which catches validation errors on the model and
// decorates the fields. Needs wiring per class, but this initialization shows how
// either have your init call this one or copy the contents
initialize : function() {
this.listenTo(this.model, 'error', CMS.ServerError);
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.selectorToField = _.invert(this.fieldToSelectorMap);
},
errorTemplate : _.template('<span class="message-error"><%= message %></span>'),
events : {
"change input" : "clearValidationErrors",
"change textarea" : "clearValidationErrors"
},
fieldToSelectorMap : {
// Your subclass must populate this w/ all of the model keys and dom selectors
// which may be the subjects of validation errors
},
_cacheValidationErrors : [],
handleValidationError : function(model, error) {
// error is object w/ fields and error strings
for (var field in error) {
var ele = this.$el.find('#' + this.fieldToSelectorMap[field]);
this._cacheValidationErrors.push(ele);
this.getInputElements(ele).addClass('error');
$(ele).parent().append(this.errorTemplate({message : error[field]}));
}
},
clearValidationErrors : function() {
// error is object w/ fields and error strings
while (this._cacheValidationErrors.length > 0) {
var ele = this._cacheValidationErrors.pop();
this.getInputElements(ele).removeClass('error');
$(ele).nextAll('.message-error').remove();
}
},
saveIfChanged : function(event) {
// returns true if the value changed and was thus sent to server
var field = this.selectorToField[event.currentTarget.id];
var currentVal = this.model.get(field);
var newVal = $(event.currentTarget).val();
this.clearValidationErrors(); // curr = new if user reverts manually
if (currentVal != newVal) {
this.model.save(field, newVal);
return true;
}
else return false;
},
// these should perhaps go into a superclass but lack of event hash inheritance demotivates me
inputFocus : function(event) {
$("label[for='" + event.currentTarget.id + "']").addClass("is-focused");
},
inputUnfocus : function(event) {
$("label[for='" + event.currentTarget.id + "']").removeClass("is-focused");
},
getInputElements: function(ele) {
var inputElements = 'input, textarea';
if ($(ele).is(inputElements)) {
return $(ele);
}
else {
// put error on the contained inputs
return $(ele).find(inputElements);
}
}
});

View File

@@ -1,65 +0,0 @@
.alert {
padding: 15px 20px;
margin-bottom: 30px;
border-radius: 3px;
border: 1px solid #edbd3c;
border-radius: 3px;
background: #fbf6e1;
// background: #edbd3c;
font-size: 14px;
@include clearfix;
.alert-message {
float: left;
margin-top: 4px;
}
strong {
font-weight: 700;
}
.alert-action {
float: right;
&.secondary {
@include orange-button;
}
}
}
body.error {
background: $darkGrey;
color: #3c3c3c;
.primary-header {
display: none;
}
.error-prompt {
width: 700px;
margin: 150px auto;
padding: 60px 50px 90px;
border-radius: 3px;
background: #fff;
text-align: center;
}
h1 {
float: none;
margin: 0;
font-size: 60px;
font-weight: 300;
color: #3c3c3c;
}
.description {
margin-bottom: 50px;
font-size: 21px;
}
.back-button {
@include blue-button;
padding: 14px 40px 18px;
font-size: 18px;
}
}

View File

@@ -1,180 +1,427 @@
// -------------------------------------
//
// Universal
//
// -------------------------------------
// studio - base styling
// ====================
body {
min-width: 980px;
background: rgb(240, 241, 245);
font-size: 16px;
line-height: 1.6;
color: $baseFontColor;
// basic setup
html {
font-size: 62.5%;
overflow-y: scroll;
}
body,
input {
font-family: 'Open Sans', sans-serif;
body {
@include font-size(16);
min-width: $fg-min-width;
background: $gray-l5;
line-height: 1.6;
color: $baseFontColor;
}
body, input {
font-family: 'Open Sans', sans-serif;
}
a {
text-decoration: none;
color: $blue;
@include transition(color .15s);
text-decoration: none;
color: $blue;
@include transition(color .15s);
&:hover {
color: #cb9c40;
}
&:hover {
color: #cb9c40;
}
}
h1 {
float: left;
font-size: 28px;
font-weight: 300;
margin: 24px 6px;
@include font-size(28);
font-weight: 300;
}
.waiting {
opacity: 0.1;
opacity: 0.1;
}
.page-actions {
margin-bottom: 30px;
margin-bottom: 30px;
}
.main-wrapper {
.wrapper {
@include clearfix();
@include box-sizing(border-box);
width: 100%;
}
// ====================
// layout - basic page header
.wrapper-mast {
margin: 0;
padding: 0 $baseline;
position: relative;
.mast, .metadata {
@include clearfix();
@include font-size(16);
position: relative;
margin: 0 40px;
max-width: $fg-max-width;
min-width: $fg-min-width;
width: flex-grid(12);
margin: 0 auto $baseline auto;
color: $gray-d2;
}
.mast {
border-bottom: 1px solid $gray-l4;
padding-bottom: ($baseline/2);
.title-sub {
@include font-size(14);
position: relative;
top: ($baseline/4);
display: block;
margin: 0;
color: $gray-l2;
font-weight: 400;
}
.title, .title-1 {
@include font-size(32);
margin: 0;
padding: 0;
font-weight: 600;
color: $gray-d3;
}
.nav-hierarchy {
@include font-size(14);
display: block;
margin: 0;
color: $gray-l2;
font-weight: 400;
.nav-item {
display: inline;
vertical-align: middle;
margin-right: ($baseline/4);
&:after {
content: ">>";
margin-left: ($baseline/4);
}
&:last-child {
margin-right: 0;
&:after {
content: none;
}
}
}
}
// layout with actions
.title {
width: flex-grid(12);
}
// layout with actions
&.has-actions {
@include clearfix();
.title {
float: left;
width: flex-grid(6,12);
margin-right: flex-gutter();
}
.nav-actions {
position: relative;
bottom: -($baseline*0.75);
float: right;
width: flex-grid(6,12);
text-align: right;
.nav-item {
display: inline-block;
vertical-align: top;
margin-right: ($baseline/2);
&:last-child {
margin-right: 0;
}
}
// buttons
.button {
padding: ($baseline/4) ($baseline/2) ($baseline/3) ($baseline/2) !important;
font-weight: 400 !important;
}
.new-button {
font-weight: 700 !important;
}
.view-button {
font-weight: 700 !important;
}
.upload-button .icon-create {
@include font-size(18);
margin-top: ($baseline/4);
}
}
}
// layout with actions
&.has-subtitle {
.nav-actions {
bottom: -($baseline*1.5);
}
}
}
// page metadata/action bar
.metadata {
}
}
// layout - basic page content
.wrapper-content {
margin: 0;
padding: 0 $baseline;
position: relative;
}
.content {
@include clearfix();
@include font-size(16);
max-width: $fg-max-width;
min-width: $fg-min-width;
width: flex-grid(12);
margin: 0 auto;
color: $gray-d2;
header {
position: relative;
margin-bottom: $baseline;
border-bottom: 1px solid $gray-l4;
padding-bottom: ($baseline/2);
.title-sub {
@include font-size(14);
display: block;
margin: 0;
color: $gray-l2;
}
.title-1 {
@include font-size(32);
margin: 0;
padding: 0;
font-weight: 600;
color: $gray-d3;
}
}
.introduction {
@include box-sizing(border-box);
@include font-size(14);
width: flex-grid(12);
margin: 0 0 $baseline 0;
.copy strong {
font-weight: 600;
}
&.has-links {
@include clearfix();
.copy {
float: left;
width: flex-grid(8,12);
margin-right: flex-gutter();
}
.nav-introduction-supplementary {
@include font-size(13);
float: right;
width: flex-grid(4,12);
display: block;
text-align: right;
.icon {
@include font-size(14);
display: inline-block;
vertical-align: middle;
margin-right: ($baseline/4);
}
}
}
}
}
.content-primary, .content-supplementary {
@include box-sizing(border-box);
}
// layout - primary content
.content-primary {
.title-1, .title-2, .title-3, .title-4, .title-5, .title-5 {
color: $gray-d3;
}
.title-1 {
}
.title-2 {
@include font-size(24);
margin: 0 0 ($baseline/2) 0;
font-weight: 600;
}
.title-3 {
@include font-size(16);
margin: 0 0 ($baseline/2) 0;
font-weight: 600;
}
.title-4 {
}
.title-5 {
}
}
// layout - supplemental content
.content-supplementary {
.bit {
@include font-size(13);
margin: 0 0 $baseline 0;
border-bottom: 1px solid $gray-l4;
padding: 0 0 $baseline 0;
color: $gray-l1;
&:last-child {
margin-bottom: 0;
border: none;
padding-bottom: 0;
}
h3 {
@include font-size(14);
margin: 0 0 ($baseline/4) 0;
color: $gray-d2;
font-weight: 600;
}
p {
margin: 0 0 $baseline 0;
&:last-child {
margin-bottom: 0;
}
}
// navigation
.nav-related, .nav-page {
.nav-item {
margin-bottom: ($baseline/4);
border-bottom: 1px dotted $gray-l4;
padding-bottom: ($baseline/4);
&:last-child {
margin-bottom: 0;
border: none;
padding-bottom: 0;
}
}
}
}
}
// ====================
// layout - grandfathered
.main-wrapper {
position: relative;
margin: 40px;
}
.inner-wrapper {
position: relative;
max-width: 1280px;
margin: auto;
@include clearfix();
position: relative;
max-width: 1280px;
margin: auto;
> article {
clear: both;
}
> article {
clear: both;
}
}
.main-column {
clear: both;
float: left;
width: 70%;
}
.sidebar {
float: right;
width: 28%;
float: right;
width: 28%;
}
.left {
float: left;
float: left;
}
.right {
float: right;
float: right;
}
footer {
clear: both;
height: 100px;
}
input[type="text"],
input[type="email"],
input[type="password"],
textarea.text {
padding: 6px 8px 8px;
@include box-sizing(border-box);
border: 1px solid $mediumGrey;
border-radius: 2px;
@include linear-gradient($lightGrey, tint($lightGrey, 90%));
background-color: $lightGrey;
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset);
font-family: 'Open Sans', sans-serif;
font-size: 11px;
color: $baseFontColor;
outline: 0;
&::-webkit-input-placeholder,
&:-moz-placeholder,
&:-ms-input-placeholder {
color: #979faf;
}
&:focus {
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
outline: 0;
}
}
input.search {
padding: 6px 15px 8px 30px;
@include box-sizing(border-box);
border: 1px solid $darkGrey;
border-radius: 20px;
background: url(../img/search-icon.png) no-repeat 8px 7px #edf1f5;
font-family: 'Open Sans', sans-serif;
color: $baseFontColor;
outline: 0;
&::-webkit-input-placeholder {
color: #979faf;
}
}
label {
font-size: 12px;
}
code {
padding: 0 4px;
border-radius: 3px;
background: #eee;
font-family: Monaco, monospace;
}
.CodeMirror {
font-size: 13px;
border: 1px solid $darkGrey;
background: #fff;
}
.text-editor {
width: 100%;
min-height: 80px;
padding: 10px;
@include box-sizing(border-box);
border: 1px solid $mediumGrey;
@include linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.3));
background-color: #edf1f5;
@include box-shadow(0 1px 2px rgba(0, 0, 0, 0.1) inset);
font-family: Monaco, monospace;
}
// ====================
// UI - actions
.new-unit-item,
.new-subsection-item,
.new-policy-item {
@include grey-button;
margin: 5px 8px;
padding: 3px 10px 4px 10px;
font-size: 10px;
@include grey-button;
margin: 5px 8px;
padding: 3px 10px 4px 10px;
font-size: 10px;
.new-folder-icon,
.new-policy-icon,
.new-unit-icon {
position: relative;
top: 2px;
}
.new-folder-icon,
.new-policy-icon,
.new-unit-icon {
position: relative;
top: 2px;
}
}
.item-actions {
position: absolute;
top: 5px;
right: 5px;
position: absolute;
top: 5px;
right: 5px;
.edit-button,
.delete-button,
.visibility-toggle {
float: left;
margin-right: 13px;
color: #a4aab7;
}
.edit-button,
.delete-button,
.visibility-toggle {
float: left;
margin-right: 13px;
color: #a4aab7;
}
}
// ====================
// misc
hr.divide {
@include text-sr();
}
.item-details {
@@ -189,81 +436,56 @@ code {
}
.window {
margin-bottom: 20px;
border: 1px solid $mediumGrey;
border-radius: 3px;
background: #fff;
@include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1));
// @include border-radius(3px);
// @include box-shadow(0 1px 1px $shadow-l1);
// margin-bottom: $baseline;
// border: 1px solid $gray-l2;
// background: $white;
.window-contents {
padding: 20px;
.window-contents {
padding: 20px;
}
.header {
padding: 6px 14px;
border-bottom: 1px solid $mediumGrey;
border-radius: 2px 2px 0 0;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: $lightBluishGrey;
@include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset);
font-size: 14px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
}
label {
display: block;
margin-bottom: 6px;
font-weight: 700;
&.inline-label {
display: inline;
}
.header {
padding: 6px 14px;
border-bottom: 1px solid $mediumGrey;
border-radius: 2px 2px 0 0;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: $lightBluishGrey;
@include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset);
font-size: 14px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
.description {
display: block;
font-size: 11px;
font-weight: 400;
font-style: italic;
line-height: 1.3;
color: #999;
}
}
label {
display: block;
margin-bottom: 6px;
font-weight: 700;
&.inline-label {
display: inline;
}
.description {
display: block;
font-size: 11px;
font-weight: 400;
font-style: italic;
line-height: 1.3;
color: #999;
}
}
.row {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #cbd1db;
}
.row {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #cbd1db;
}
}
body.hide-wip {
.wip, .wip-box {
display: none !important;
}
}
// ====================
body.show-wip {
.wip {
outline: 1px solid #f00 !important;
position: relative;
}
.wip-box {
@extend .wip;
&:after {
content: "WIP";
font-size: 8px;
padding: 2px;
background: #f00;
color: #fff;
@include position(absolute, 0px 0px 0 0);
}
}
}
.waiting {
}
// system notifications
.toast-notification {
display: none;
position: fixed;
@@ -323,59 +545,86 @@ body.show-wip {
}
.waiting {
position: relative;
position: relative;
&:before {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 999998;
width: 100%;
height: 100%;
border-radius: inherit;
background: rgba(255, 255, 255, .9);
}
&:before {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 999998;
width: 100%;
height: 100%;
border-radius: inherit;
background: rgba(255, 255, 255, .9);
}
&:after {
content: '';
@extend .spinner-icon;
display: block;
position: absolute;
top: 50%;
left: 50%;
margin-left: -10px;
margin-top: -10px;
z-index: 999999;
}
&:after {
content: '';
@extend .spinner-icon;
display: block;
position: absolute;
top: 50%;
left: 50%;
margin-left: -10px;
margin-top: -10px;
z-index: 999999;
}
}
.waiting-inline {
&:after {
content: '';
@extend .spinner-icon;
}
&:after {
content: '';
@extend .spinner-icon;
}
}
.new-button {
@include green-button;
font-size: 13px;
padding: 8px 20px 10px;
text-align: center;
@include green-button;
@include font-size(13);
padding: 8px 20px 10px;
text-align: center;
&.big {
display: block;
}
&.big {
display: block;
}
.icon-create {
display: inline-block;
vertical-align: middle;
margin-right: ($baseline/4);
margin-top: ($baseline/10);
line-height: 0;
}
}
.view-button {
@include blue-button;
@include font-size(13);
text-align: center;
&.big {
display: block;
}
.icon-view {
@include font-size(15);
display: inline-block;
vertical-align: middle;
margin-right: ($baseline/2);
margin-top: ($baseline/5);
line-height: 0;
}
}
.edit-button.standard,
.delete-button.standard {
float: left;
@include font-size(12);
@include white-button;
float: left;
padding: 3px 10px 4px;
margin-left: 7px;
font-size: 12px;
font-weight: 400;
.edit-icon,
@@ -386,9 +635,9 @@ body.show-wip {
.delete-button.standard {
&:hover {
background-color: tint($orange, 75%);
}
&:hover {
background-color: tint($orange, 75%);
}
}
.tooltip {
@@ -417,4 +666,108 @@ body.show-wip {
font-size: 20px;
color: rgba(0, 0, 0, 0.85);
}
}
// ====================
// basic utility
.sr {
@include text-sr();
}
.fake-link {
cursor: pointer;
}
.non-list {
list-style: none;
margin: 0;
padding: 0;
}
.wrap {
text-wrap: wrap;
white-space: pre-wrap;
white-space: -moz-pre-wrap;
word-wrap: break-word;
}
hr.divider {
@extend .sr;
}
// ====================
// js dependant
body.js {
// lean/simple modal window
.content-modal {
@include border-bottom-radius(2px);
@include box-sizing(border-box);
@include box-shadow(0 2px 4px $shadow-d1);
position: relative;
display: none;
width: 700px;
overflow: hidden;
border: 1px solid $gray-d1;
padding: ($baseline);
background: $white;
.action-modal-close {
@include transition(top .25s ease-in-out);
@include border-bottom-radius(3px);
position: absolute;
top: -3px;
right: $baseline;
padding: ($baseline/4) ($baseline/2) 0 ($baseline/2);
background: $gray-l3;
text-align: center;
.label {
@include text-sr();
}
.ss-icon {
@include font-size(18);
color: $white;
}
&:hover {
background: $blue;
top: 0;
}
}
img {
@include box-sizing(border-box);
width: 100%;
overflow-y: scroll;
padding: ($baseline/10);
border: 1px solid $gray-l4;
}
.title {
@include font-size(18);
margin: 0 0 ($baseline/2) 0;
font-weight: 600;
color: $gray-d3;
}
.description {
@include font-size(13);
margin-top: ($baseline/2);
color: $gray-l1;
}
}
}
// ====================
// works in progress
body.hide-wip {
.wip-box {
display: none;
}
}

View File

@@ -1,367 +0,0 @@
section.cal {
@include box-sizing(border-box);
@include clearfix;
padding: 20px;
> header {
display: none;
@include clearfix;
margin-bottom: 10px;
opacity: .4;
@include transition;
text-shadow: 0 1px 0 #fff;
&:hover {
opacity: 1;
}
h2 {
@include inline-block();
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
padding: 6px 6px 6px 0;
font-size: 12px;
margin: 0;
}
ul {
@include inline-block;
float: right;
margin: 0;
padding: 0;
&.actions {
float: left;
}
li {
@include inline-block;
margin-right: 6px;
border-right: 1px solid #ddd;
padding: 0 6px 0 0;
&:last-child {
border-right: 0;
margin-right: 0;
padding-right: 0;
}
a {
@include inline-block();
font-size: 12px;
@include inline-block;
margin: 0 6px;
font-style: italic;
}
ul {
@include inline-block();
margin: 0;
li {
@include inline-block();
padding: 0;
border-left: 0;
}
}
}
}
}
ol {
list-style: none;
@include clearfix;
border: 1px solid lighten( $dark-blue , 30% );
background: #FFF;
width: 100%;
@include box-sizing(border-box);
margin: 0;
padding: 0;
@include box-shadow(0 0 5px lighten($dark-blue, 45%));
@include border-radius(3px);
overflow: hidden;
> li {
border-right: 1px solid lighten($dark-blue, 40%);
border-bottom: 1px solid lighten($dark-blue, 40%);
@include box-sizing(border-box);
float: left;
width: flex-grid(3) + ((flex-gutter() * 3) / 4);
background-color: $light-blue;
@include box-shadow(inset 0 0 0 1px lighten($light-blue, 8%));
&:hover {
li.create-module {
opacity: 1;
}
}
&:nth-child(4n) {
border-right: 0;
}
header {
border-bottom: 1px solid lighten($dark-blue, 40%);
@include box-shadow(0 2px 2px $light-blue);
display: block;
margin-bottom: 2px;
background: #FFF;
h1 {
font-size: 14px;
text-transform: uppercase;
border-bottom: 1px solid lighten($dark-blue, 60%);
padding: 6px;
color: $bright-blue;
margin: 0;
a {
color: $bright-blue;
display: block;
padding: 6px;
margin: -6px;
&:hover {
color: darken($bright-blue, 10%);
background: lighten($yellow, 10%);
}
}
}
ul {
margin: 0;
padding: 0;
li {
background: #fff;
color: #888;
border-bottom: 0;
font-size: 12px;
@include box-shadow(none);
}
}
}
ul {
list-style: none;
margin: 0 0 1px 0;
padding: 0;
li {
border-bottom: 1px solid darken($light-blue, 6%);
// @include box-shadow(0 1px 0 lighten($light-blue, 4%));
overflow: hidden;
position: relative;
text-shadow: 0 1px 0 #fff;
&:hover {
background-color: lighten($yellow, 14%);
a.draggable {
background-color: lighten($yellow, 14%);
opacity: 1;
}
}
&.editable {
padding: 3px 6px;
}
a {
color: lighten($dark-blue, 10%);
display: block;
padding: 6px 35px 6px 6px;
&:hover {
background-color: lighten($yellow, 10%);
}
&.draggable {
background-color: $light-blue;
opacity: .3;
padding: 0;
&:hover {
background-color: lighten($yellow, 10%);
}
}
}
&.create-module {
position: relative;
opacity: 0;
@include transition(all 3s ease-in-out);
background: darken($light-blue, 2%);
> div {
background: $dark-blue;
@include box-shadow(0 0 5px darken($light-blue, 60%));
@include box-sizing(border-box);
display: none;
margin-left: 3%;
padding: 10px;
@include position(absolute, 30px 0 0 0);
width: 90%;
z-index: 99;
ul {
li {
border-bottom: 0;
background: none;
input {
@include box-sizing(border-box);
width: 100%;
}
select {
@include box-sizing(border-box);
width: 100%;
option {
font-size: 14px;
}
}
div {
margin-top: 10px;
}
a {
color: $light-blue;
float: right;
&:first-child {
float: left;
}
&:hover {
color: #fff;
}
}
}
}
}
}
}
}
}
}
section.new-section {
margin: 10px 0 40px;
@include inline-block();
position: relative;
> a {
@extend .button;
display: block;
}
section {
display: none;
@include position(absolute, 30px 0 0 0);
background: rgba(#000, .8);
min-width: 300px;
padding: 10px;
@include box-sizing(border-box);
@include border-radius(3px);
z-index: 99;
&:before {
content: " ";
display: block;
background: rgba(#000, .8);
width: 10px;
height: 10px;
@include position(absolute, -5px 0 0 20%);
@include transform(rotate(45deg));
}
form {
ul {
list-style: none;
li {
border-bottom: 0;
background: none;
margin-bottom: 6px;
input {
width: 100%;
@include box-sizing(border-box);
border-color: #000;
padding: 6px;
}
select {
width: 100%;
@include box-sizing(border-box);
option {
font-size: 14px;
}
}
a {
float: right;
&:first-child {
float: left;
}
}
}
}
}
}
}
}
body.content
section.cal {
width: flex-grid(3);
float: left;
overflow: scroll;
@include box-sizing(border-box);
opacity: .4;
@include transition();
&:hover {
opacity: 1;
}
> header {
@include transition;
overflow: hidden;
> a {
display: none;
}
ul {
float: none;
display: block;
li {
ul {
display: inline;
}
}
}
}
ol {
li {
@include box-sizing(border-box);
width: 100%;
border-right: 0;
&.create-module {
display: none;
}
}
}
}

View File

@@ -1,3 +1,6 @@
// studio - utilities - mixins and extends
// ====================
@mixin clearfix {
&:after {
content: '';
@@ -28,7 +31,7 @@
}
}
&:hover {
&:hover, &.active {
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset, 0 1px 1px rgba(0, 0, 0, .15));
}
}
@@ -41,7 +44,7 @@
background-color: $blue;
color: #fff;
&:hover {
&:hover, &.active {
background-color: #62aaf5;
color: #fff;
}
@@ -285,4 +288,11 @@
padding: 0;
position: absolute;
width: 1px;
}
@mixin active {
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: rgba(255, 255, 255, .3);
@include box-shadow(0 -1px 0 rgba(0, 0, 0, .2) inset, 0 1px 0 #fff inset);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
}

View File

@@ -1,679 +0,0 @@
input.courseware-unit-search-input {
float: left;
width: 260px;
background-color: #fff;
}
.branch {
.section-item {
@include clearfix();
.details {
display: block;
float: left;
margin-bottom: 0;
width: 650px;
}
.gradable-status {
float: right;
position: relative;
top: -4px;
right: 50px;
width: 145px;
.status-label {
position: absolute;
top: 2px;
right: -5px;
display: none;
width: 110px;
padding: 5px 40px 5px 10px;
@include border-radius(3px);
color: $lightGrey;
text-align: right;
font-size: 12px;
font-weight: bold;
line-height: 16px;
}
.menu-toggle {
z-index: 10;
position: absolute;
top: 0;
right: 5px;
padding: 5px;
color: $mediumGrey;
&:hover, &.is-active {
color: $blue;
}
}
.menu {
z-index: 1;
display: none;
opacity: 0.0;
position: absolute;
top: -1px;
left: 5px;
margin: 0;
padding: 8px 12px;
background: $white;
border: 1px solid $mediumGrey;
font-size: 12px;
@include border-radius(4px);
@include box-shadow(0 1px 2px rgba(0, 0, 0, .2));
@include transition(opacity .15s);
li {
width: 115px;
margin-bottom: 3px;
padding-bottom: 3px;
border-bottom: 1px solid $lightGrey;
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border: none;
a {
color: $darkGrey;
}
}
}
a {
color: $blue;
&.is-selected {
font-weight: bold;
}
}
}
// dropdown state
&.is-active {
.menu {
z-index: 1000;
display: block;
opacity: 1.0;
}
.menu-toggle {
z-index: 10000;
}
}
// set state
&.is-set {
.menu-toggle {
color: $blue;
}
.status-label {
display: block;
color: $blue;
}
}
}
}
}
.courseware-section {
position: relative;
background: #fff;
border-radius: 3px;
border: 1px solid $mediumGrey;
margin-top: 15px;
padding-bottom: 12px;
@include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1));
&:first-child {
margin-top: 0;
}
&.collapsed {
padding-bottom: 0;
}
label {
float: left;
line-height: 29px;
}
.datepair {
float: left;
margin-left: 10px;
}
.section-published-date {
position: absolute;
top: 19px;
right: 90px;
padding: 4px 10px;
border-radius: 3px;
background: $lightGrey;
text-align: right;
.published-status {
font-size: 12px;
margin-right: 15px;
strong {
font-weight: bold;
}
}
.schedule-button {
@include blue-button;
}
.edit-button {
@include blue-button;
}
.schedule-button,
.edit-button {
font-size: 11px;
padding: 3px 15px 5px;
}
}
.datepair .date,
.datepair .time {
padding-left: 0;
padding-right: 0;
border: none;
background: none;
@include box-shadow(none);
font-size: 13px;
font-weight: bold;
color: $blue;
cursor: pointer;
}
.datepair .date {
width: 80px;
}
.datepair .time {
width: 65px;
}
&.collapsed .subsection-list,
.collapsed .subsection-list,
.collapsed > ol {
display: none !important;
}
header {
min-height: 75px;
@include clearfix();
.item-details, .section-published-date {
}
.item-details {
display: inline-block;
padding: 20px 0 10px 0;
@include clearfix();
.section-name {
float: left;
margin-right: 10px;
width: 350px;
font-size: 19px;
font-weight: bold;
color: $blue;
}
.section-name-span {
cursor: pointer;
@include transition(color .15s);
&:hover {
color: $orange;
}
}
.section-name-edit {
position: relative;
width: 400px;
background: $white;
input {
font-size: 16px;
}
.save-button {
@include blue-button;
padding: 7px 20px 7px;
margin-right: 5px;
}
.cancel-button {
@include white-button;
padding: 7px 20px 7px;
}
}
.section-published-date {
float: right;
width: 265px;
margin-right: 220px;
@include border-radius(3px);
background: $lightGrey;
.published-status {
font-size: 12px;
margin-right: 15px;
strong {
font-weight: bold;
}
}
.schedule-button {
@include blue-button;
}
.edit-button {
@include blue-button;
}
.schedule-button,
.edit-button {
font-size: 11px;
padding: 3px 15px 5px;
}
}
.gradable-status {
position: absolute;
top: 20px;
right: 70px;
width: 145px;
.status-label {
position: absolute;
top: 0;
right: 2px;
display: none;
width: 100px;
padding: 10px 35px 10px 10px;
@include border-radius(3px);
background: $lightGrey;
color: $lightGrey;
text-align: right;
font-size: 12px;
font-weight: bold;
line-height: 16px;
}
.menu-toggle {
z-index: 10;
position: absolute;
top: 2px;
right: 5px;
padding: 5px;
color: $lightGrey;
&:hover, &.is-active {
color: $blue;
}
}
.menu {
z-index: 1;
display: none;
opacity: 0.0;
position: absolute;
top: -1px;
left: 2px;
margin: 0;
padding: 8px 12px;
background: $white;
border: 1px solid $mediumGrey;
font-size: 12px;
@include border-radius(4px);
@include box-shadow(0 1px 2px rgba(0, 0, 0, .2));
@include transition(opacity .15s);
@include transition(display .15s);
li {
width: 115px;
margin-bottom: 3px;
padding-bottom: 3px;
border-bottom: 1px solid $lightGrey;
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border: none;
a {
color: $darkGrey;
}
}
}
a {
&.is-selected {
font-weight: bold;
}
}
}
// dropdown state
&.is-active {
.menu {
z-index: 1000;
display: block;
opacity: 1.0;
}
.menu-toggle {
z-index: 10000;
}
}
// set state
&.is-set {
.menu-toggle {
color: $blue;
}
.status-label {
display: block;
color: $blue;
}
}
float: left;
padding: 21px 0 0;
}
}
.item-actions {
margin-top: 21px;
margin-right: 12px;
.edit-button,
.delete-button {
margin-top: -3px;
}
}
.expand-collapse-icon {
float: left;
margin: 29px 6px 16px 16px;
@include transition(none);
&.expand {
background-position: 0 0;
}
&.collapsed {
}
}
.drag-handle {
margin-left: 11px;
}
}
h3 {
font-size: 19px;
font-weight: 700;
color: $blue;
}
.section-name-span {
cursor: pointer;
@include transition(color .15s);
&:hover {
color: $orange;
}
}
.section-name-form {
margin-bottom: 15px;
}
.section-name-edit {
input {
font-size: 16px;
}
.save-button {
@include blue-button;
padding: 7px 20px 7px;
margin-right: 5px;
}
.cancel-button {
@include white-button;
padding: 7px 20px 7px;
}
}
h4 {
font-size: 12px;
color: #878e9d;
strong {
font-weight: bold;
}
}
.list-header {
@include linear-gradient(top, transparent, rgba(0, 0, 0, .1));
background-color: #ced2db;
border-radius: 3px 3px 0 0;
}
.subsection-list {
margin: 0 12px;
> ol {
@include tree-view;
border-top-width: 0;
}
}
&.new-section {
header {
height: auto;
@include clearfix();
}
.expand-collapse-icon {
visibility: hidden;
}
}
}
.toggle-button-sections {
display: none;
position: relative;
float: right;
margin-top: 10px;
font-size: 13px;
color: $darkGrey;
&.is-shown {
display: block;
}
.ss-icon {
@include border-radius(20px);
position: relative;
top: -1px;
display: inline-block;
margin-right: 2px;
line-height: 5px;
font-size: 11px;
}
.label {
display: inline-block;
}
}
.new-section-name,
.new-subsection-name-input {
width: 515px;
}
.new-section-name-save,
.new-subsection-name-save {
@include blue-button;
padding: 4px 20px 7px;
margin: 0 5px;
color: #fff !important;
}
.new-section-name-cancel,
.new-subsection-name-cancel {
@include white-button;
padding: 4px 20px 7px;
color: #8891a1 !important;
}
.dummy-calendar {
display: none;
position: absolute;
top: 55px;
left: 110px;
z-index: 9999;
border: 1px solid #3C3C3C;
@include box-shadow(0 1px 15px rgba(0, 0, 0, .2));
}
.unit-name-input {
padding: 20px 40px;
label {
display: block;
}
input {
width: 100%;
font-size: 20px;
}
}
.preview {
background: url(../img/preview.jpg) center top no-repeat;
}
.edit-subsection-publish-settings {
display: none;
position: fixed;
top: 100px;
left: 50%;
z-index: 99999;
width: 600px;
margin-left: -300px;
background: #fff;
text-align: center;
.settings {
padding: 40px;
}
h3 {
font-size: 34px;
font-weight: 300;
}
.picker {
margin: 30px 0 65px;
}
.description {
margin-top: 30px;
font-size: 14px;
line-height: 20px;
}
strong {
font-weight: 700;
}
.start-date,
.start-time {
font-size: 19px;
}
.save-button {
@include blue-button;
margin-right: 10px;
}
.cancel-button {
@include white-button;
}
.save-button,
.cancel-button {
font-size: 16px;
}
}
.collapse-all-button {
float: right;
margin-top: 10px;
font-size: 13px;
color: $darkGrey;
}
// sort/drag and drop
.ui-droppable {
@include transition (padding 0.5s ease-in-out 0s);
min-height: 20px;
padding: 0;
&.dropover {
padding: 15px 0;
}
}
.ui-draggable-dragging {
@include box-shadow(0 1px 2px rgba(0, 0, 0, .3));
border: 1px solid $darkGrey;
opacity : 0.2;
&:hover {
opacity : 1.0;
.section-item {
background: $yellow !important;
}
}
// hiding unit button - temporary fix until this semantically corrected
.new-unit-item {
display: none;
}
}
ol.ui-droppable .branch:first-child .section-item {
border-top: none;
}

View File

@@ -1,90 +0,0 @@
.class-list {
margin-top: 20px;
border-radius: 3px;
border: 1px solid $darkGrey;
background: #fff;
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1));
li {
border-bottom: 1px solid $mediumGrey;
&:last-child {
border-bottom: none;
}
}
a {
padding: 20px 25px;
line-height: 1.3;
&:hover {
background: $paleYellow;
}
}
.class-name {
display: block;
font-size: 19px;
font-weight: 300;
}
.detail {
font-size: 14px;
font-weight: 400;
margin-right: 20px;
color: #3c3c3c;
}
}
.new-course {
padding: 15px 25px;
margin-top: 20px;
border-radius: 3px;
border: 1px solid $darkGrey;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, .1);
@include clearfix;
.row {
margin-bottom: 15px;
@include clearfix;
}
.column {
float: left;
width: 48%;
}
.column:first-child {
margin-right: 4%;
}
.course-info {
width: 600px;
}
label {
display: block;
font-size: 13px;
font-weight: 700;
}
.new-course-org,
.new-course-number,
.new-course-name {
width: 100%;
}
.new-course-name {
font-size: 19px;
font-weight: 300;
}
.new-course-save {
@include blue-button;
}
.new-course-cancel {
@include white-button;
}
}

View File

@@ -1,109 +0,0 @@
body.no-header {
.primary-header {
display: none;
}
}
@mixin active {
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: rgba(255, 255, 255, .3);
@include box-shadow(0 -1px 0 rgba(0, 0, 0, .2) inset, 0 1px 0 #fff inset);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
}
.primary-header {
width: 100%;
margin-bottom: 30px;
&.active-tab-courseware #courseware-tab {
@include active;
}
&.active-tab-assets #assets-tab {
@include active;
}
&.active-tab-pages #pages-tab {
@include active;
}
&.active-tab-users #users-tab {
@include active;
}
&.active-tab-settings #settings-tab {
@include active;
}
&.active-tab-import #import-tab {
@include active;
}
&.active-tab-export #export-tab {
@include active;
}
.drop-icon {
margin-left: 5px;
font-size: 11px;
}
.settings-icon {
font-size: 18px;
line-height: 18px;
}
.class-nav-bar {
clear: both;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: $lightBluishGrey;
@include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset);
}
.class-nav {
@include clearfix;
a {
float: left;
display: inline-block;
padding: 15px 25px 17px;
font-size: 15px;
color: #3c3c3c;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
&:hover {
@include linear-gradient(top, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0));
}
}
li {
float: left;
}
}
.class {
@include clearfix;
height: 100%;
font-size: 12px;
color: rgb(163, 171, 184);
@include linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, .1));
background-color: rgb(47, 53, 63);
a {
display: inline-block;
height: 20px;
padding: 5px 10px 6px;
color: rgb(163, 171, 184);
}
.home {
position: relative;
top: 1px;
}
.log-out {
position: relative;
top: 3px;
}
}
}

View File

@@ -1,80 +0,0 @@
body.index {
> header {
display: none;
}
> h1 {
font-weight: 300;
color: lighten($dark-blue, 40%);
text-shadow: 0 1px 0 #fff;
-webkit-font-smoothing: antialiased;
max-width: 600px;
text-align: center;
margin: 80px auto 30px;
}
section.main-container {
border-right: 3px;
background: #FFF;
max-width: 600px;
margin: 0 auto;
display: block;
@include box-sizing(border-box);
border: 1px solid lighten( $dark-blue , 30% );
@include border-radius(3px);
overflow: hidden;
@include bounce-in-animation(.8s);
header {
border-bottom: 1px solid lighten($dark-blue, 50%);
@include linear-gradient(#fff, lighten($dark-blue, 62%));
@include clearfix();
@include box-shadow( 0 2px 0 $light-blue, inset 0 -1px 0 #fff);
text-shadow: 0 1px 0 #fff;
h1 {
font-size: 14px;
padding: 8px 20px;
float: left;
color: $dark-blue;
margin: 0;
}
a {
float: right;
padding: 8px 20px;
border-left: 1px solid lighten($dark-blue, 50%);
@include box-shadow( inset -1px 0 0 #fff);
font-weight: bold;
font-size: 22px;
line-height: 1;
color: $dark-blue;
}
}
ol {
list-style: none;
margin: 0;
padding: 0;
li {
border-bottom: 1px solid lighten($dark-blue, 50%);
a {
display: block;
padding: 10px 20px;
&:hover {
color: $dark-blue;
background: lighten($yellow, 10%);
text-shadow: 0 1px 0 #fff;
}
}
&:last-child {
border-bottom: none;
}
}
}
}
}

View File

@@ -1,126 +0,0 @@
// This is a temporary page, which will be replaced once we have a more extensive course catalog and marketing site for edX labs.
.class-landing {
.main-wrapper {
width: 700px !important;
margin: 100px auto;
}
.class-info {
padding: 30px 40px 40px;
@extend .window;
hgroup {
padding-bottom: 26px;
border-bottom: 1px solid $mediumGrey;
}
h1 {
float: none;
font-size: 30px;
font-weight: 300;
margin: 0;
}
h2 {
color: #5d6779;
}
.class-actions {
@include clearfix;
padding: 15px 0;
margin-bottom: 18px;
border-bottom: 1px solid $mediumGrey;
}
.log-in-form {
@include clearfix;
padding: 15px 0 20px;
margin-bottom: 18px;
border-bottom: 1px solid $mediumGrey;
.log-in-submit-button {
@include blue-button;
padding: 6px 20px 8px;
margin: 24px 0 0;
}
.column {
float: left;
width: 41%;
margin-right: 1%;
&.submit {
width: 16%;
margin-right: 0;
}
label {
float: left;
}
}
input {
width: 100%;
font-family: $sans-serif;
font-size: 13px;
}
.forgot-button {
float: right;
margin-bottom: 6px;
font-size: 12px;
}
}
.sign-up-button {
@include blue-button;
display: block;
width: 250px;
margin: auto;
}
.log-in-button {
@include white-button;
float: right;
}
.sign-up-button,
.log-in-button {
padding: 8px 0 12px;
font-size: 18px;
font-weight: 300;
text-align: center;
}
.class-description {
margin-top: 30px;
font-size: 14px;
}
p + p {
margin-top: 22px;
}
}
.edx-labs-logo-small {
display: block;
width: 124px;
height: 30px;
margin: auto;
background: url(../img/edx-labs-logo-small.png) no-repeat;
text-indent: -9999px;
overflow: hidden;
}
.edge-logo {
display: block;
width: 143px;
height: 39px;
margin: auto;
background: url(../images/edge-logo-small.png) no-repeat;
text-indent: -9999px;
overflow: hidden;
}
}

View File

@@ -1,125 +0,0 @@
body {
@include clearfix();
height: 100%;
font: 14px $body-font-family;
background-color: lighten($dark-blue, 62%);
background-image: url('/static/img/noise.png');
> section {
display: table;
table-layout: fixed;
width: 100%;
}
> header {
background: $dark-blue;
@include background-image(url('/static/img/noise.png'), linear-gradient(lighten($dark-blue, 10%), $dark-blue));
border-bottom: 1px solid darken($dark-blue, 15%);
@include box-shadow(inset 0 -1px 0 lighten($dark-blue, 10%));
@include box-sizing(border-box);
color: #fff;
display: block;
float: none;
padding: 0 20px;
text-shadow: 0 -1px 0 darken($dark-blue, 15%);
width: 100%;
nav {
@include clearfix;
> a {
@include hide-text;
background: url('/static/img/menu.png') 0 center no-repeat;
border-right: 1px solid darken($dark-blue, 10%);
@include box-shadow(1px 0 0 lighten($dark-blue, 10%));
display: block;
float: left;
height: 19px;
padding: 8px 10px 8px 0;
width: 14px;
&:hover, &:focus {
opacity: .7;
}
}
h2 {
border-right: 1px solid darken($dark-blue, 10%);
@include box-shadow(1px 0 0 lighten($dark-blue, 10%));
float: left;
font-size: 14px;
margin: 0;
text-transform: uppercase;
-webkit-font-smoothing: antialiased;
a {
color: #fff;
padding: 8px 20px;
display: block;
&:hover {
background-color: rgba(darken($dark-blue, 15%), .5);
color: $yellow;
}
}
}
a {
color: rgba(#fff, .8);
&:hover {
color: rgba(#fff, .6);
}
}
ul {
float: left;
margin: 0;
padding: 0;
@include clearfix;
&.user-nav {
float: right;
border-left: 1px solid darken($dark-blue, 10%);
}
li {
border-right: 1px solid darken($dark-blue, 10%);
float: left;
@include box-shadow(1px 0 0 lighten($dark-blue, 10%));
a {
padding: 8px 20px;
display: block;
&:hover {
background-color: rgba(darken($dark-blue, 15%), .5);
color: $yellow;
}
&.new-module {
&:before {
@include inline-block;
content: "+";
font-weight: bold;
margin-right: 10px;
}
}
}
}
}
}
}
&.content {
section.main-content {
border-left: 2px solid $dark-blue;
@include box-sizing(border-box);
width: flex-grid(9) + flex-gutter();
float: left;
@include box-shadow( -2px 0 0 lighten($dark-blue, 55%));
@include transition();
background: #FFF;
}
}
}

View File

@@ -1,69 +0,0 @@
.component {
font-family: 'Open Sans', Verdana, Arial, Helvetica, sans-serif;
font-size: 16px;
line-height: 1.6;
color: #3c3c3c;
a {
color: #1d9dd9;
text-decoration: none;
}
p {
font-size: 16px;
line-height: 1.6;
}
h1 {
float: none;
}
h2 {
color: #646464;
font-size: 19px;
font-weight: 300;
letter-spacing: 1px;
margin-bottom: 15px;
margin-left: 0;
text-transform: uppercase;
}
h3 {
font-size: 19px;
font-weight: 400;
}
h4 {
background: none;
padding: 0;
border: none;
@include box-shadow(none);
font-size: 16px;
font-weight: 400;
}
code {
margin: 0 2px;
padding: 0px 5px;
border-radius: 3px;
border: 1px solid #eaeaea;
white-space: nowrap;
font-family: Monaco, monospace;
font-size: 14px;
background-color: #f8f8f8;
}
p + h2, ul + h2, ol + h2, p + h3 {
margin-top: 40px;
}
p + p, ul + p, ol + p {
margin-top: 20px;
}
p {
color: #3c3c3c;
font: normal 1em/1.6em;
margin: 0px;
}
}

View File

@@ -1,139 +0,0 @@
.edx-studio-logo-large {
display: block;
width: 224px;
height: 45px;
margin: 100px auto 30px;
background: url(../img/edx-studio-large.png) no-repeat;
}
.sign-up-box,
.log-in-box {
width: 500px;
margin: auto;
border-radius: 3px;
header {
height: 36px;
border-radius: 3px 3px 0 0;
border: 1px solid #2c2e33;
@include linear-gradient(top, #686b76, #54565e);
color: #fff;
@include box-shadow(0 1px 1px rgba(0, 0, 0, 0.2), 0 -1px 0px rgba(255, 255, 255, 0.05) inset, 0 1px 0 rgba(255, 255, 255, .25) inset);
h1 {
float: none;
margin: 5px 0;
font-size: 15px;
font-weight: 300;
text-align: center;
}
}
form {
padding: 40px;
border: 1px solid $darkGrey;
border-top-width: 0;
border-radius: 0 0 3px 3px;
background: #fff;
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1));
}
label {
display: block;
margin-bottom: 5px;
font-size: 13px;
font-weight: 700;
}
input[type="text"],
input[type="email"],
input[type="password"] {
width: 100%;
font-size: 20px;
font-weight: 300;
}
.row {
@include clearfix;
margin-bottom: 24px;
.split {
float: left;
width: 48%;
&:first-child {
margin-right: 4%;
}
}
}
.form-actions {
@include clearfix;
margin-top: 32px;
margin-bottom: 5px;
text-align: center;
}
.log-in-button,
.create-account-button {
@include blue-button;
padding: 8px 0 10px;
font-family: $sans-serif;
@include transition(all .15s);
}
.create-account-button {
padding: 10px 40px 12px;
margin-bottom: 10px;
}
.enrolled {
font-size: 14px;
}
.sign-up-button {
@include white-button;
padding: 7px 0 9px;
}
.log-in-button,
.sign-up-button {
@include box-sizing(border-box);
float: left;
width: 45%;
}
.or {
float: left;
display: inline-block;
width: 10%;
font-size: 15px;
line-height: 36px;
color: $darkGrey;
text-align: center;
}
.forgot-button {
float: right;
font-size: 11px;
font-weight: 400;
line-height: 21px;
}
.log-in-extra {
margin-top: 10px;
text-align: right;
font-size: 13px;
}
#login_error,
#register_error {
display: none;
margin-bottom: 30px;
padding: 5px 10px;
border-radius: 3px;
background: $error-red;
font-size: 14px;
color: #fff;
}
}

View File

@@ -1,128 +0,0 @@
section.video-new, section.video-edit, section.problem-new, section.problem-edit {
position: absolute;
top: 72px;
right: 0;
background: #fff;
width: flex-grid(6);
@include box-shadow(0 0 6px #666);
border: 1px solid #333;
border-right: 0;
z-index: 4;
> header {
background: #666;
@include clearfix;
color: #fff;
padding: 6px;
border-bottom: 1px solid #333;
-webkit-font-smoothing: antialiased;
h2 {
float: left;
font-size: 14px;
}
a {
color: #fff;
&.save-update {
float: right;
}
&.cancel {
float: left;
}
}
}
> section {
padding: 20px;
> header {
h1 {
font-size: 24px;
margin: 12px 0;
}
section {
&.status-settings {
ul {
list-style: none;
@include border-radius(2px);
border: 1px solid #999;
@include inline-block();
li {
@include inline-block();
border-right: 1px solid #999;
padding: 6px;
&:last-child {
border-right: 0;
}
&.current {
background: #eee;
}
}
}
a.settings {
@include inline-block();
margin: 0 20px;
border: 1px solid #999;
padding: 6px;
}
select {
float: right;
}
}
&.meta {
background: #eee;
padding: 10px;
margin: 20px 0;
@include clearfix();
div {
float: left;
margin-right: 20px;
h2 {
font-size: 14px;
@include inline-block();
}
p {
@include inline-block();
}
}
}
}
}
section.notes {
margin-top: 20px;
padding: 6px;
background: #eee;
border: 1px solid #ccc;
textarea {
@include box-sizing(border-box);
display: block;
width: 100%;
}
h2 {
font-size: 14px;
margin-bottom: 6px;
}
input[type="submit"]{
margin-top: 10px;
}
}
}
}

View File

@@ -1,24 +0,0 @@
section.problem-new, section.problem-edit {
> section {
textarea {
@include box-sizing(border-box);
display: block;
width: 100%;
}
div.preview {
background: #eee;
@include box-sizing(border-box);
height: 40px;
padding: 10px;
width: 100%;
}
a.save {
@extend .button;
@include inline-block();
margin-top: 20px;
}
}
}

View File

@@ -1,3 +1,10 @@
// studio - utilities - reset
// ====================
// * {
// @include box-sizing(border-box);
// }
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
@@ -18,7 +25,7 @@ time, mark, audio, video {
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
@@ -38,12 +45,6 @@ q:before, q:after {
content: none;
}
/* remember to define visible focus styles!
:focus {
outline: ?????;
} */
/* remember to highlight inserts somehow! */
ins {
text-decoration: none;
}
@@ -54,4 +55,94 @@ del {
table {
border-collapse: collapse;
border-spacing: 0;
}
// ====================
// grandfathered styles
// reset styles to remove ui-lightness jquery ui theme from the tabs component (used in the add component problem tab menu)
.ui-tabs {
padding: 0;
white-space: normal;
}
.ui-corner-all, .ui-corner-bottom, .ui-corner-left, ui-corner-top, .ui-corner-br, .ui-corner-right {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: 0;
border-top-left-radius: 0;
}
.ui-widget-content {
border: 0;
background: none;
}
.ui-widget {
font-family: 'Open Sans', sans-serif;
font-size: 16px;
}
.ui-widget-header {
border:none;
background: none;
}
.ui-tabs .ui-tabs-nav {
padding: 0;
}
.ui-tabs .ui-tabs-nav li {
margin: 0;
padding: 0;
border: none;
top: 0;
margin: 0;
float: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.ui-tabs-nav {
li {
top: 0;
margin: 0;
}
a {
float: none;
font-weight: normal;
}
}
.ui-tabs .ui-tabs-panel {
padding: 0;
}
// reapplying the tab styles from unit.scss after removing jquery ui ui-lightness styling
.problem-type-tabs {
border:none;
list-style-type: none;
width: 100%;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
//background-color: $lightBluishGrey;
@include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset);
li:first-child {
margin-left: 20px;
}
li {
opacity: .8;
&:ui-state-active {
background-color: rgba(255, 255, 255, .3);
opacity: 1;
font-weight: 400;
}
a:focus {
outline: none;
border: 0px;
}
}
}

View File

@@ -1,239 +0,0 @@
section#unit-wrapper {
section.filters {
@include clearfix;
display: none;
opacity: .4;
margin-bottom: 10px;
@include transition;
&:hover {
opacity: 1;
}
h2 {
@include inline-block();
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
padding: 6px 6px 6px 0;
font-size: 12px;
margin: 0;
}
ul {
@include clearfix();
list-style: none;
margin: 0;
padding: 0;
li {
@include inline-block;
margin-right: 6px;
border-right: 1px solid #ddd;
padding-right: 6px;
&.search {
float: right;
border: 0;
}
a {
&.more {
font-size: 12px;
@include inline-block;
margin: 0 6px;
font-style: italic;
}
}
}
}
}
div.content {
display: table;
border: 1px solid lighten($dark-blue, 40%);
width: 100%;
@include border-radius(3px);
@include box-shadow(0 0 4px lighten($dark-blue, 50%));
section {
header {
background: #fff;
padding: 6px;
border-bottom: 1px solid lighten($dark-blue, 60%);
@include clearfix;
h2 {
color: $bright-blue;
// float: left;
font-size: 14px;
letter-spacing: 1px;
// line-height: 20px;
text-transform: uppercase;
margin: 0;
}
}
&.modules {
@include box-sizing(border-box);
display: table-cell;
width: flex-grid(6, 9);
border-right: 1px solid lighten($dark-blue, 40%);
&.empty {
text-align: center;
vertical-align: middle;
a {
@extend .button;
@include inline-block();
margin-top: 10px;
}
}
ol {
list-style: none;
margin: 0;
padding: 0;
li {
border-bottom: 1px solid lighten($dark-blue, 60%);
a {
color: #000;
}
ol {
list-style: none;
margin: 0;
padding: 0;
li {
padding: 6px;
position: relative;
&:last-child {
border-bottom: 0;
}
&:hover {
background-color: lighten($yellow, 10%);
a.draggable {
opacity: 1;
}
}
a.draggable {
float: right;
opacity: .4;
}
&.group {
padding: 0;
header {
padding: 6px;
background: none;
h3 {
font-size: 14px;
margin: 0;
}
}
ol {
border-left: 4px solid #999;
border-bottom: 0;
margin: 0;
padding: 0;
li {
&:last-child {
border-bottom: 0;
}
}
}
}
}
}
}
}
}
&.scratch-pad {
@include box-sizing(border-box);
display: table-cell;
width: flex-grid(3, 9) + flex-gutter(9);
vertical-align: top;
ol {
list-style: none;
margin: 0;
padding: 0;
li {
background: $light-blue;
&:last-child {
border-bottom: 0;
}
&.new-module a {
background-color: darken($light-blue, 2%);
border-bottom: 1px solid darken($light-blue, 8%);
&:hover {
background-color: lighten($yellow, 10%);
}
}
a {
color: $dark-blue;
}
ul {
list-style: none;
margin: 0;
padding: 0;
li {
padding: 6px;
border-collapse: collapse;
border-bottom: 1px solid darken($light-blue, 8%);
position: relative;
&:last-child {
border-bottom: 1px solid darken($light-blue, 8%);
}
&:hover {
background-color: lighten($yellow, 10%);
a.draggable {
opacity: 1;
}
}
&.empty {
padding: 12px;
a {
@extend .button;
display: block;
text-align: center;
}
}
a.draggable {
opacity: .3;
}
}
}
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More