Merge remote-tracking branch 'origin/master' into feature/vik/oe-ui
Conflicts: common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py common/lib/xmodule/xmodule/peer_grading_module.py lms/templates/combinedopenended/combined_open_ended.html lms/templates/combinedopenended/combined_open_ended_status.html lms/templates/combinedopenended/openended/open_ended.html lms/templates/combinedopenended/openended/open_ended_rubric.html lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html
This commit is contained in:
4
AUTHORS
4
AUTHORS
@@ -53,7 +53,7 @@ Christina Roberts <christina@edx.org>
|
||||
Robert Chirwa <robert@edx.org>
|
||||
Ed Zarecor <ed@edx.org>
|
||||
Deena Wang <thedeenawang@gmail.com>
|
||||
Jean Manuel-Nater <jnater@edx.org>
|
||||
Jean Manuel Náter <jnater@edx.org>
|
||||
Emily Zhang <1800.ehz.hang@gmail.com>
|
||||
Jennifer Akana <jaakana@gmail.com>
|
||||
Peter Baratta <peter.baratta@gmail.com>
|
||||
@@ -83,4 +83,4 @@ Ian Hoover <ihoover@edx.org>
|
||||
Mukul Goyal <miki@edx.org>
|
||||
Robert Marks <rmarks@edx.org>
|
||||
Yarko Tymciurak <yarkot1@gmail.com>
|
||||
|
||||
Miles Steele <miles@milessteele.com>
|
||||
|
||||
@@ -5,6 +5,33 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
Studio: Email will be sent to admin address when a user requests course creator
|
||||
privileges for Studio (edge only).
|
||||
|
||||
Studio: Studio course authors (both instructors and staff) will be auto-enrolled
|
||||
for their courses so that "View Live" works.
|
||||
|
||||
Common: Added ratelimiting to our authentication backend.
|
||||
|
||||
Common: Add additional logging to cover login attempts and logouts.
|
||||
|
||||
Studio: Send e-mails to new Studio users (on edge only) when their course creator
|
||||
status has changed. This will not be in use until the course creator table
|
||||
is enabled.
|
||||
|
||||
Studio: Added improvements to Course Creation: richer error messaging, tip
|
||||
text, and fourth field for course run.
|
||||
|
||||
Blades: New features for VideoAlpha player:
|
||||
1.) Controls are auto hidden after a delay of mouse inactivity - the full video
|
||||
becomes visible.
|
||||
2.) When captions (CC) button is pressed, captions stick (not auto hidden after
|
||||
a delay of mouse inactivity). The video player size does not change - the video
|
||||
is down-sized and placed in the middle of the black area.
|
||||
3.) All source code of Video Alpha 2 is written in JavaScript. It is not a basic
|
||||
conversion from CoffeeScript. The structure of the player has been changed.
|
||||
4.) A lot of additional unit tests.
|
||||
|
||||
LMS: Added user preferences (arbitrary user/key/value tuples, for which
|
||||
which user/key is unique) and a REST API for reading users and
|
||||
preferences. Access to the REST API is restricted by use of the
|
||||
@@ -14,10 +41,16 @@ the setting is not present, the API is disabled).
|
||||
LMS: Added endpoints for AJAX requests to enable/disable notifications
|
||||
(which are not yet implemented) and a one-click unsubscribe page.
|
||||
|
||||
Studio: Allow instructors of a course to designate other staff as instructors;
|
||||
this allows instructors to hand off management of a course to someone else.
|
||||
|
||||
Common: Add a manage.py that knows about edx-platform specific settings and projects
|
||||
|
||||
Common: Added *experimental* support for jsinput type.
|
||||
|
||||
Studio: Remove XML from HTML5 video component editor. All settings are
|
||||
moved to be edited as metadata.
|
||||
|
||||
Common: Added setting to specify Celery Broker vhost
|
||||
|
||||
Common: Utilize new XBlock bulk save API in LMS and CMS.
|
||||
|
||||
@@ -17,10 +17,10 @@ installation process.
|
||||
Providers. You should use VirtualBox >= 4.2.12.
|
||||
(Windows: later/earlier VirtualBox versions than 4.2.12 have been reported to not work well with
|
||||
Vagrant. If this is still a problem, you can
|
||||
install 4.2.12 from https://www.virtualbox.org/wiki/Download_Old_Builds_4_2).
|
||||
install 4.2.12 from http://download.virtualbox.org/virtualbox/4.2.12/).
|
||||
4. Install Vagrant: http://www.vagrantup.com/ (Vagrant 1.2.2 or later)
|
||||
5. Open a terminal
|
||||
6. Download the project: `git clone git://github.com/edx/edx-platform.git`
|
||||
6. Download the project: `git clone https://github.com/edx/edx-platform.git`
|
||||
7. Enter the project directory: `cd edx-platform/`
|
||||
8. (Windows only) Run the commands to
|
||||
[deal with line endings and symlinks under Windows](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#dealing-with-line-endings-and-symlinks-under-windows)
|
||||
|
||||
@@ -178,10 +178,12 @@ def _remove_user_from_group(user, group_name):
|
||||
user.save()
|
||||
|
||||
|
||||
def is_user_in_course_group_role(user, location, role):
|
||||
def is_user_in_course_group_role(user, location, role, check_staff=True):
|
||||
if user.is_active and user.is_authenticated:
|
||||
# all "is_staff" flagged accounts belong to all groups
|
||||
return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0
|
||||
if check_staff and user.is_staff:
|
||||
return True
|
||||
return user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from lxml import html
|
||||
from lxml import html, etree
|
||||
import re
|
||||
from django.http import HttpResponseBadRequest
|
||||
import logging
|
||||
@@ -74,34 +74,44 @@ def update_course_updates(location, update, passed_id=None):
|
||||
escaped = django.utils.html.escape(course_updates.data)
|
||||
course_html_parsed = html.fromstring("<ol><li>" + escaped + "</li></ol>")
|
||||
|
||||
# if there's no ol, create it
|
||||
if course_html_parsed.tag != 'ol':
|
||||
# surround whatever's there w/ an ol
|
||||
if course_html_parsed.tag != 'li':
|
||||
# but first wrap in an li
|
||||
li = etree.Element('li')
|
||||
li.append(course_html_parsed)
|
||||
course_html_parsed = li
|
||||
ol = etree.Element('ol')
|
||||
ol.append(course_html_parsed)
|
||||
course_html_parsed = 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>')
|
||||
|
||||
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
|
||||
if course_html_parsed.tag == 'ol':
|
||||
# ??? Should this use the id in the json or in the url or does it matter?
|
||||
if passed_id is not None:
|
||||
idx = get_idx(passed_id)
|
||||
# idx is count from end of list
|
||||
course_html_parsed[-idx] = new_html_parsed
|
||||
else:
|
||||
course_html_parsed.insert(0, new_html_parsed)
|
||||
# ??? Should this use the id in the json or in the url or does it matter?
|
||||
if passed_id is not None:
|
||||
idx = get_idx(passed_id)
|
||||
# idx is count from end of list
|
||||
course_html_parsed[-idx] = new_html_parsed
|
||||
else:
|
||||
course_html_parsed.insert(0, new_html_parsed)
|
||||
|
||||
idx = len(course_html_parsed)
|
||||
passed_id = course_updates.location.url() + "/" + str(idx)
|
||||
idx = len(course_html_parsed)
|
||||
passed_id = course_updates.location.url() + "/" + str(idx)
|
||||
|
||||
# update db record
|
||||
course_updates.data = html.tostring(course_html_parsed)
|
||||
modulestore('direct').update_item(location, course_updates.data)
|
||||
# update db record
|
||||
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:]])
|
||||
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": content}
|
||||
return {"id": passed_id,
|
||||
"date": update['date'],
|
||||
"content": content}
|
||||
|
||||
|
||||
def delete_course_update(location, update, passed_id):
|
||||
|
||||
@@ -40,6 +40,7 @@ Feature: Advanced (manual) course policy
|
||||
And I reload the page
|
||||
Then the policy key value is unchanged
|
||||
|
||||
# This feature will work in Firefox only when Firefox is the active window
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches
|
||||
from common import type_in_codemirror, press_the_notification_button
|
||||
|
||||
KEY_CSS = '.key input.policy-key'
|
||||
@@ -90,18 +90,18 @@ def the_policy_key_value_is_changed(step):
|
||||
|
||||
############# 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")
|
||||
for key, value in zip(expected_keys, expected_values):
|
||||
index = get_index_of(key)
|
||||
assert_false(index == -1, "Could not find key: {key}".format(key=key))
|
||||
assert_equal(value, 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_value(KEY_CSS, index=counter)
|
||||
for i, element in enumerate(world.css_find(KEY_CSS)):
|
||||
# Sometimes get stale reference if I hold on to the array of elements
|
||||
key = world.css_value(KEY_CSS, index=i)
|
||||
if key == expected_key:
|
||||
return counter
|
||||
return i
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Feature: Course checklists
|
||||
Then I can check and uncheck tasks in a checklist
|
||||
And They are correctly selected after reloading the page
|
||||
|
||||
# CHROME ONLY, due to issues getting link to be active in firefox
|
||||
Scenario: A task can link to a location within Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to the course outline
|
||||
@@ -17,6 +18,7 @@ Feature: Course checklists
|
||||
And I press the browser back button
|
||||
Then I am brought back to the course outline in the correct state
|
||||
|
||||
# CHROME ONLY, due to issues getting link to be active in firefox
|
||||
Scenario: A task can link to a location outside Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to help page
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true
|
||||
|
||||
from auth.authz import get_user_by_email
|
||||
from auth.authz import get_user_by_email, get_course_groupname_for_role
|
||||
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
import time
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
@@ -53,6 +54,12 @@ def i_have_opened_a_new_course(_step):
|
||||
open_new_course()
|
||||
|
||||
|
||||
@step('(I select|s?he selects) the new course')
|
||||
def select_new_course(_step, whom):
|
||||
course_link_css = 'a.course-link'
|
||||
world.css_click(course_link_css)
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(_step, name):
|
||||
css = 'a.action-%s' % name.lower()
|
||||
@@ -63,8 +70,12 @@ def press_the_notification_button(_step, name):
|
||||
confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning')
|
||||
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
|
||||
return confirmation_dismissed or error_showing
|
||||
|
||||
world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name
|
||||
if world.is_firefox():
|
||||
# This is done to explicitly make the changes save on firefox. It will remove focus from the previously focused element
|
||||
world.trigger_event(css, event='focus')
|
||||
world.browser.execute_script("$('{}').click()".format(css))
|
||||
else:
|
||||
world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name
|
||||
|
||||
|
||||
@step('I change the "(.*)" field to "(.*)"$')
|
||||
@@ -118,14 +129,18 @@ def create_studio_user(
|
||||
registration.register(studio_user)
|
||||
registration.activate()
|
||||
|
||||
return studio_user
|
||||
|
||||
|
||||
def fill_in_course_info(
|
||||
name='Robot Super Course',
|
||||
org='MITx',
|
||||
num='999'):
|
||||
num='101',
|
||||
run='2013_Spring'):
|
||||
world.css_fill('.new-course-name', name)
|
||||
world.css_fill('.new-course-org', org)
|
||||
world.css_fill('.new-course-number', num)
|
||||
world.css_fill('.new-course-run', run)
|
||||
|
||||
|
||||
def log_into_studio(
|
||||
@@ -133,40 +148,30 @@ def log_into_studio(
|
||||
email='robot+studio@edx.org',
|
||||
password='test'):
|
||||
|
||||
world.browser.cookies.delete()
|
||||
world.log_in(username=uname, password=password, email=email, name='Robot Studio')
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
|
||||
signin_css = 'a.action-signin'
|
||||
world.is_css_present(signin_css)
|
||||
world.css_click(signin_css)
|
||||
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill(email)
|
||||
login_form.find_by_name('password').fill(password)
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
assert_true(world.is_css_present('.new-course-button'))
|
||||
world.scenario_dict['USER'] = get_user_by_email(email)
|
||||
|
||||
|
||||
def create_a_course():
|
||||
world.scenario_dict['COURSE'] = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
world.scenario_dict['COURSE'] = course
|
||||
|
||||
user = world.scenario_dict.get("USER")
|
||||
if not user:
|
||||
user = get_user_by_email('robot+studio@edx.org')
|
||||
|
||||
# Add the user to the instructor group of the course
|
||||
# so they will have the permissions to see it in studio
|
||||
|
||||
course = world.GroupFactory.create(name='instructor_MITx/{}/{}'.format(world.scenario_dict['COURSE'].number,
|
||||
world.scenario_dict['COURSE'].display_name.replace(" ", "_")))
|
||||
if world.scenario_dict.get('USER') is None:
|
||||
user = world.scenario_dict['USER']
|
||||
else:
|
||||
user = get_user_by_email('robot+studio@edx.org')
|
||||
user.groups.add(course)
|
||||
for role in ("staff", "instructor"):
|
||||
groupname = get_course_groupname_for_role(course.location, role)
|
||||
group, __ = Group.objects.get_or_create(name=groupname)
|
||||
user.groups.add(group)
|
||||
user.save()
|
||||
world.browser.reload()
|
||||
|
||||
course_link_css = 'span.class-name'
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
course_link_css = 'a.course-link'
|
||||
world.css_click(course_link_css)
|
||||
course_title_css = 'span.course-title'
|
||||
assert_true(world.is_css_present(course_title_css))
|
||||
@@ -214,6 +219,26 @@ def i_created_a_video_component(step):
|
||||
)
|
||||
|
||||
|
||||
@step('I have created a Video Alpha component$')
|
||||
def i_created_video_alpha(step):
|
||||
step.given('I have enabled the videoalpha advanced module')
|
||||
world.css_click('a.course-link')
|
||||
step.given('I have added a new subsection')
|
||||
step.given('I expand the first section')
|
||||
world.css_click('a.new-unit-item')
|
||||
world.css_click('.large-advanced-icon')
|
||||
world.click_component_from_menu('videoalpha', None, '.xmodule_VideoAlphaModule')
|
||||
|
||||
|
||||
@step('I have enabled the (.*) advanced module$')
|
||||
def i_enabled_the_advanced_module(step, module):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
world.css_click('.nav-course-settings')
|
||||
world.css_click('.nav-course-settings-advanced a')
|
||||
type_in_codemirror(0, '["%s"]' % module)
|
||||
press_the_notification_button(step, 'Save')
|
||||
|
||||
|
||||
@step('I have clicked the new unit button')
|
||||
def open_new_unit(step):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
@@ -222,14 +247,14 @@ def open_new_unit(step):
|
||||
world.css_click('a.new-unit-item')
|
||||
|
||||
|
||||
@step('when I view the video it (.*) show the captions')
|
||||
def shows_captions(step, show_captions):
|
||||
@step('when I view the (video.*) it (.*) show the captions')
|
||||
def shows_captions(_step, video_type, show_captions):
|
||||
# Prevent cookies from overriding course settings
|
||||
world.browser.cookies.delete('hide_captions')
|
||||
if show_captions == 'does not':
|
||||
assert world.css_has_class('.video', 'closed')
|
||||
assert world.css_has_class('.%s' % video_type, 'closed')
|
||||
else:
|
||||
assert world.is_css_not_present('.video.closed')
|
||||
assert world.is_css_not_present('.%s.closed' % video_type)
|
||||
|
||||
|
||||
@step('the save button is disabled$')
|
||||
@@ -242,7 +267,7 @@ def save_button_disabled(step):
|
||||
@step('I confirm the prompt')
|
||||
def confirm_the_prompt(step):
|
||||
prompt_css = 'a.button.action-primary'
|
||||
world.css_click(prompt_css)
|
||||
world.css_click(prompt_css, success_condition=lambda: not world.css_visible(prompt_css))
|
||||
|
||||
|
||||
@step(u'I am shown a (.*)$')
|
||||
@@ -251,7 +276,8 @@ def i_am_shown_a_notification(step, notification_type):
|
||||
|
||||
|
||||
def type_in_codemirror(index, text):
|
||||
world.css_click(".CodeMirror", index=index)
|
||||
world.css_click("div.CodeMirror-lines", index=index)
|
||||
world.browser.execute_script("$('div.CodeMirror.CodeMirror-focused > div').css('overflow', '')")
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
if world.is_mac():
|
||||
g._element.send_keys(Keys.COMMAND + 'a')
|
||||
@@ -259,3 +285,5 @@ def type_in_codemirror(index, text):
|
||||
g._element.send_keys(Keys.CONTROL + 'a')
|
||||
g._element.send_keys(Keys.DELETE)
|
||||
g._element.send_keys(text)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('div.CodeMirror', index=index, event='blur')
|
||||
|
||||
@@ -12,11 +12,20 @@ def create_component_instance(step, component_button_css, category,
|
||||
has_multiple_templates=True):
|
||||
|
||||
click_new_component_button(step, component_button_css)
|
||||
if category in ('problem', 'html'):
|
||||
def animation_done(_driver):
|
||||
return world.browser.evaluate_script("$('div.new-component').css('display')") == 'none'
|
||||
world.wait_for(animation_done)
|
||||
|
||||
if has_multiple_templates:
|
||||
click_component_from_menu(category, boilerplate, expected_css)
|
||||
|
||||
assert_equal(1, len(world.css_find(expected_css)))
|
||||
assert_equal(
|
||||
1,
|
||||
len(world.css_find(expected_css)),
|
||||
"Component instance with css {css} was not created successfully".format(css=expected_css))
|
||||
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_new_component_button(step, component_button_css):
|
||||
@@ -39,19 +48,32 @@ def click_component_from_menu(category, boilerplate, expected_css):
|
||||
elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category)
|
||||
elements = world.css_find(elem_css)
|
||||
assert_equal(len(elements), 1)
|
||||
world.css_click(elem_css)
|
||||
world.wait_for(lambda _driver: world.css_visible(elem_css))
|
||||
world.css_click(elem_css, success_condition=lambda: 1 == len(world.css_find(expected_css)))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def edit_component_and_select_settings():
|
||||
world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
|
||||
world.css_click('a.edit-button')
|
||||
world.css_click('#settings-mode a')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def edit_component():
|
||||
world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
|
||||
world.css_click('a.edit-button')
|
||||
world.css_click('#settings-mode')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def verify_setting_entry(setting, display_name, value, explicitly_set):
|
||||
assert_equal(display_name, setting.find_by_css('.setting-label')[0].value)
|
||||
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
|
||||
# Check specifically for the list type; it has a different structure
|
||||
if setting.has_class('metadata-list-enum'):
|
||||
list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item'))
|
||||
assert_equal(value, list_value)
|
||||
else:
|
||||
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
|
||||
settingClearButton = setting.find_by_css('.setting-clear')[0]
|
||||
assert_equal(explicitly_set, settingClearButton.has_class('active'))
|
||||
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
|
||||
@@ -92,8 +114,20 @@ def revert_setting_entry(label):
|
||||
|
||||
@world.absorb
|
||||
def get_setting_entry(label):
|
||||
settings = world.browser.find_by_css('.wrapper-comp-setting')
|
||||
for setting in settings:
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return setting
|
||||
return None
|
||||
def get_setting():
|
||||
settings = world.css_find('.wrapper-comp-setting')
|
||||
for setting in settings:
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return setting
|
||||
return None
|
||||
return world.retry_on_exception(get_setting)
|
||||
|
||||
@world.absorb
|
||||
def get_setting_entry_index(label):
|
||||
def get_index():
|
||||
settings = world.css_find('.wrapper-comp-setting')
|
||||
for index, setting in enumerate(settings):
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return index
|
||||
return None
|
||||
return world.retry_on_exception(get_index)
|
||||
|
||||
@@ -63,3 +63,10 @@ Feature: Course Overview
|
||||
When I navigate to the course overview page
|
||||
And I change an assignment's grading status
|
||||
Then I am shown a notification
|
||||
|
||||
Scenario: Notification is shown on subsection reorder
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
And I have added a new subsection
|
||||
When I reorder subsections
|
||||
Then I am shown a notification
|
||||
|
||||
@@ -52,7 +52,7 @@ def have_a_course_with_two_sections(step):
|
||||
def navigate_to_the_course_overview_page(step):
|
||||
create_studio_user(is_staff=True)
|
||||
log_into_studio()
|
||||
course_locator = '.class-name'
|
||||
course_locator = 'a.course-link'
|
||||
world.css_click(course_locator)
|
||||
|
||||
|
||||
@@ -124,3 +124,14 @@ def all_sections_are_collapsed(step):
|
||||
def change_grading_status(step):
|
||||
world.css_find('a.menu-toggle').click()
|
||||
world.css_find('.menu li').first.click()
|
||||
|
||||
|
||||
@step(u'I reorder subsections')
|
||||
def reorder_subsections(_step):
|
||||
draggable_css = 'a.drag-handle'
|
||||
ele = world.css_find(draggable_css).first
|
||||
ele.action_chains.drag_and_drop_by_offset(
|
||||
ele._element,
|
||||
30,
|
||||
0
|
||||
).perform()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Feature: Course Team
|
||||
As a course author, I want to be able to add others to my team
|
||||
|
||||
Scenario: Users can add other users
|
||||
Scenario: Admins can add other users
|
||||
Given I have opened a new course in Studio
|
||||
And the user "alice" exists
|
||||
And I am viewing the course team settings
|
||||
@@ -9,16 +9,18 @@ Feature: Course Team
|
||||
And "alice" logs in
|
||||
Then she does see the course on her page
|
||||
|
||||
Scenario: Added users cannot delete or add other users
|
||||
Scenario: Added admins cannot delete or add other users
|
||||
Given I have opened a new course in Studio
|
||||
And the user "bob" exists
|
||||
And I am viewing the course team settings
|
||||
When I add "bob" to the course team
|
||||
And "bob" logs in
|
||||
And he selects the new course
|
||||
And he views the course team settings
|
||||
Then he cannot delete users
|
||||
And he cannot add users
|
||||
|
||||
Scenario: Users can delete other users
|
||||
Scenario: Admins can delete other users
|
||||
Given I have opened a new course in Studio
|
||||
And the user "carol" exists
|
||||
And I am viewing the course team settings
|
||||
@@ -27,8 +29,60 @@ Feature: Course Team
|
||||
And "carol" logs in
|
||||
Then she does not see the course on her page
|
||||
|
||||
Scenario: Users cannot add users that do not exist
|
||||
Scenario: Admins cannot add users that do not exist
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the course team settings
|
||||
When I add "dennis" to the course team
|
||||
Then I should see "Could not find user by email address" somewhere on the page
|
||||
|
||||
Scenario: Admins should be able to make other people into admins
|
||||
Given I have opened a new course in Studio
|
||||
And the user "emily" exists
|
||||
And I am viewing the course team settings
|
||||
And I add "emily" to the course team
|
||||
When I make "emily" a course team admin
|
||||
And "emily" logs in
|
||||
And she selects the new course
|
||||
And she views the course team settings
|
||||
Then "emily" should be marked as an admin
|
||||
And she can add users
|
||||
And she can delete users
|
||||
|
||||
Scenario: Admins should be able to remove other admins
|
||||
Given I have opened a new course in Studio
|
||||
And the user "frank" exists as a course admin
|
||||
And I am viewing the course team settings
|
||||
When I remove admin rights from "frank"
|
||||
And "frank" logs in
|
||||
And he selects the new course
|
||||
And he views the course team settings
|
||||
Then "frank" should not be marked as an admin
|
||||
And he cannot add users
|
||||
And he cannot delete users
|
||||
|
||||
Scenario: Admins should be able to give course ownership to someone else
|
||||
Given I have opened a new course in Studio
|
||||
And the user "gina" exists
|
||||
And I am viewing the course team settings
|
||||
When I add "gina" to the course team
|
||||
And I make "gina" a course team admin
|
||||
And I remove admin rights from myself
|
||||
And "gina" logs in
|
||||
And she selects the new course
|
||||
And she views the course team settings
|
||||
And she deletes me from the course team
|
||||
And I log in
|
||||
Then I do not see the course on my page
|
||||
|
||||
Scenario: Admins should be able to remove their own admin rights
|
||||
Given I have opened a new course in Studio
|
||||
And the user "harry" exists as a course admin
|
||||
And I am viewing the course team settings
|
||||
Then I should be marked as an admin
|
||||
And I can add users
|
||||
And I can delete users
|
||||
When I remove admin rights from myself
|
||||
Then I should not be marked as an admin
|
||||
And I cannot add users
|
||||
And I cannot delete users
|
||||
And I cannot make myself a course team admin
|
||||
|
||||
@@ -3,40 +3,88 @@
|
||||
|
||||
from lettuce import world, step
|
||||
from common import create_studio_user, log_into_studio
|
||||
from django.contrib.auth.models import Group
|
||||
from auth.authz import get_course_groupname_for_role
|
||||
|
||||
PASSWORD = 'test'
|
||||
EMAIL_EXTENSION = '@edx.org'
|
||||
|
||||
|
||||
@step(u'I am viewing the course team settings')
|
||||
def view_grading_settings(_step):
|
||||
@step(u'(I am viewing|s?he views) the course team settings')
|
||||
def view_grading_settings(_step, whom):
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-team a'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step(u'the user "([^"]*)" exists$')
|
||||
def create_other_user(_step, name):
|
||||
create_studio_user(uname=name, password=PASSWORD, email=(name + EMAIL_EXTENSION))
|
||||
@step(u'the user "([^"]*)" exists( as a course (admin|staff member))?$')
|
||||
def create_other_user(_step, name, has_extra_perms, role_name):
|
||||
email = name + EMAIL_EXTENSION
|
||||
user = create_studio_user(uname=name, password=PASSWORD, email=email)
|
||||
if has_extra_perms:
|
||||
location = world.scenario_dict["COURSE"].location
|
||||
if role_name == "admin":
|
||||
# admins get staff privileges, as well
|
||||
roles = ("staff", "instructor")
|
||||
else:
|
||||
roles = ("staff",)
|
||||
for role in roles:
|
||||
groupname = get_course_groupname_for_role(location, role)
|
||||
group, __ = Group.objects.get_or_create(name=groupname)
|
||||
user.groups.add(group)
|
||||
user.save()
|
||||
|
||||
|
||||
@step(u'I add "([^"]*)" to the course team')
|
||||
def add_other_user(_step, name):
|
||||
new_user_css = 'a.new-user-button'
|
||||
new_user_css = 'a.create-user-button'
|
||||
world.css_click(new_user_css)
|
||||
world.wait(0.5)
|
||||
|
||||
email_css = 'input.email-input'
|
||||
f = world.css_find(email_css)
|
||||
f._element.send_keys(name, EMAIL_EXTENSION)
|
||||
|
||||
confirm_css = '#add_user'
|
||||
email_css = 'input#user-email-input'
|
||||
world.css_fill(email_css, name + EMAIL_EXTENSION)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(email_css)
|
||||
confirm_css = 'form.create-user button.action-primary'
|
||||
world.css_click(confirm_css)
|
||||
|
||||
|
||||
@step(u'I delete "([^"]*)" from the course team')
|
||||
def delete_other_user(_step, name):
|
||||
to_delete_css = 'a.remove-user[data-id="{name}{extension}"]'.format(name=name, extension=EMAIL_EXTENSION)
|
||||
to_delete_css = '.user-item .item-actions a.remove-user[data-id="{email}"]'.format(
|
||||
email="{0}{1}".format(name, EMAIL_EXTENSION))
|
||||
world.css_click(to_delete_css)
|
||||
# confirm prompt
|
||||
# need to wait for the animation to be done, there isn't a good success condition that won't work both on latest chrome and jenkins
|
||||
world.wait(.5)
|
||||
world.css_click(".wrapper-prompt-warning .action-primary")
|
||||
|
||||
|
||||
@step(u's?he deletes me from the course team')
|
||||
def other_delete_self(_step):
|
||||
to_delete_css = '.user-item .item-actions a.remove-user[data-id="{email}"]'.format(
|
||||
email="robot+studio@edx.org")
|
||||
world.css_click(to_delete_css)
|
||||
# confirm prompt
|
||||
world.css_click(".wrapper-prompt-warning .action-primary")
|
||||
|
||||
|
||||
@step(u'I make "([^"]*)" a course team admin')
|
||||
def make_course_team_admin(_step, name):
|
||||
admin_btn_css = '.user-item[data-email="{email}"] .user-actions .add-admin-role'.format(
|
||||
email=name+EMAIL_EXTENSION)
|
||||
world.css_click(admin_btn_css)
|
||||
|
||||
|
||||
@step(u'I remove admin rights from ("([^"]*)"|myself)')
|
||||
def remove_course_team_admin(_step, outer_capture, name):
|
||||
if outer_capture == "myself":
|
||||
email = world.scenario_dict["USER"].email
|
||||
else:
|
||||
email = name + EMAIL_EXTENSION
|
||||
admin_btn_css = '.user-item[data-email="{email}"] .user-actions .remove-admin-role'.format(
|
||||
email=email)
|
||||
world.css_click(admin_btn_css)
|
||||
|
||||
|
||||
@step(u'"([^"]*)" logs in$')
|
||||
@@ -44,24 +92,62 @@ def other_user_login(_step, name):
|
||||
log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION)
|
||||
|
||||
|
||||
@step(u'I( do not)? see the course on my page')
|
||||
@step(u's?he does( not)? see the course on (his|her) page')
|
||||
def see_course(_step, doesnt_see_course, gender):
|
||||
class_css = 'span.class-name'
|
||||
def see_course(_step, inverted, gender='self'):
|
||||
class_css = 'h3.course-title'
|
||||
all_courses = world.css_find(class_css, wait_time=1)
|
||||
all_names = [item.html for item in all_courses]
|
||||
if doesnt_see_course:
|
||||
if inverted:
|
||||
assert not world.scenario_dict['COURSE'].display_name in all_names
|
||||
else:
|
||||
assert world.scenario_dict['COURSE'].display_name in all_names
|
||||
|
||||
|
||||
@step(u's?he cannot delete users')
|
||||
def cannot_delete(_step):
|
||||
@step(u'"([^"]*)" should( not)? be marked as an admin')
|
||||
def marked_as_admin(_step, name, inverted):
|
||||
flag_css = '.user-item[data-email="{email}"] .flag-role.flag-role-admin'.format(
|
||||
email=name+EMAIL_EXTENSION)
|
||||
if inverted:
|
||||
assert world.is_css_not_present(flag_css)
|
||||
else:
|
||||
assert world.is_css_present(flag_css)
|
||||
|
||||
|
||||
@step(u'I should( not)? be marked as an admin')
|
||||
def self_marked_as_admin(_step, inverted):
|
||||
return marked_as_admin(_step, "robot+studio", inverted)
|
||||
|
||||
|
||||
@step(u'I can(not)? delete users')
|
||||
@step(u's?he can(not)? delete users')
|
||||
def can_delete_users(_step, inverted):
|
||||
to_delete_css = 'a.remove-user'
|
||||
assert world.is_css_not_present(to_delete_css)
|
||||
if inverted:
|
||||
assert world.is_css_not_present(to_delete_css)
|
||||
else:
|
||||
assert world.is_css_present(to_delete_css)
|
||||
|
||||
|
||||
@step(u's?he cannot add users')
|
||||
def cannot_add(_step):
|
||||
add_css = 'a.new-user'
|
||||
assert world.is_css_not_present(add_css)
|
||||
@step(u'I can(not)? add users')
|
||||
@step(u's?he can(not)? add users')
|
||||
def can_add_users(_step, inverted):
|
||||
add_css = 'a.create-user-button'
|
||||
if inverted:
|
||||
assert world.is_css_not_present(add_css)
|
||||
else:
|
||||
assert world.is_css_present(add_css)
|
||||
|
||||
|
||||
@step(u'I can(not)? make ("([^"]*)"|myself) a course team admin')
|
||||
@step(u's?he can(not)? make ("([^"]*)"|me) a course team admin')
|
||||
def can_make_course_admin(_step, inverted, outer_capture, name):
|
||||
if outer_capture == "myself":
|
||||
email = world.scenario_dict["USER"].email
|
||||
else:
|
||||
email = name + EMAIL_EXTENSION
|
||||
add_button_css = '.user-item[data-email="{email}"] .add-admin-role'.format(email=email)
|
||||
if inverted:
|
||||
assert world.is_css_not_present(add_button_css)
|
||||
else:
|
||||
assert world.is_css_present(add_button_css)
|
||||
|
||||
@@ -6,6 +6,7 @@ Feature: Course updates
|
||||
And I go to the course updates page
|
||||
When I add a new update with the text "Hello"
|
||||
Then I should see the update "Hello"
|
||||
And I see a "saving" notification
|
||||
|
||||
Scenario: Users can edit updates
|
||||
Given I have opened a new course in Studio
|
||||
@@ -13,15 +14,16 @@ Feature: Course updates
|
||||
When I add a new update with the text "Hello"
|
||||
And I modify the text to "Goodbye"
|
||||
Then I should see the update "Goodbye"
|
||||
And I see a "saving" notification
|
||||
|
||||
Scenario: Users can delete updates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
And I add a new update with the text "Hello"
|
||||
When I will confirm all alerts
|
||||
And I delete the update
|
||||
And I confirm the prompt
|
||||
Then I should not see the update "Hello"
|
||||
|
||||
And I see a "deleting" notification
|
||||
|
||||
Scenario: Users can edit update dates
|
||||
Given I have opened a new course in Studio
|
||||
@@ -29,9 +31,11 @@ Feature: Course updates
|
||||
And I add a new update with the text "Hello"
|
||||
When I edit the date to "June 1, 2013"
|
||||
Then I should see the date "June 1, 2013"
|
||||
And I see a "saving" notification
|
||||
|
||||
Scenario: Users can change handouts
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
When I modify the handout to "<ol>Test</ol>"
|
||||
Then I see the handout "Test"
|
||||
And I see a "saving" notification
|
||||
|
||||
@@ -9,7 +9,7 @@ from common import type_in_codemirror
|
||||
@step(u'I go to the course updates page')
|
||||
def go_to_updates(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
updates_css = 'li.nav-course-courseware-updates'
|
||||
updates_css = 'li.nav-course-courseware-updates a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(updates_css)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ def i_create_a_course(step):
|
||||
|
||||
@step('I click the course link in My Courses$')
|
||||
def i_click_the_course_link_in_my_courses(step):
|
||||
course_css = 'span.class-name'
|
||||
course_css = 'a.course-link'
|
||||
world.css_click(course_css)
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
@@ -44,7 +44,7 @@ def courseware_page_has_loaded_in_studio(step):
|
||||
|
||||
@step('I see the course listed in My Courses$')
|
||||
def i_see_the_course_in_my_courses(step):
|
||||
course_css = 'span.class-name'
|
||||
course_css = 'h3.class-title'
|
||||
assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name)
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ def i_see_only_the_settings_and_values(step):
|
||||
world.verify_all_setting_entries(
|
||||
[
|
||||
['Category', "Week 1", False],
|
||||
['Display Name', "Discussion Tag", False],
|
||||
['Display Name', "Discussion", False],
|
||||
['Subcategory', "Topic-Level Student-Visible Label", False]
|
||||
])
|
||||
|
||||
|
||||
@@ -11,3 +11,8 @@ Feature: HTML Editor
|
||||
And I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
|
||||
Scenario: Edit High Level source is available for LaTeX html
|
||||
Given I have created an E-text Written in LaTeX
|
||||
When I edit and select Settings
|
||||
Then Edit High Level Source is visible
|
||||
|
||||
@@ -15,3 +15,14 @@ def i_created_blank_html_page(step):
|
||||
@step('I see only the HTML display name setting$')
|
||||
def i_see_only_the_html_display_name(step):
|
||||
world.verify_all_setting_entries([['Display Name', "Text", False]])
|
||||
|
||||
|
||||
@step('I have created an E-text Written in LaTeX$')
|
||||
def i_created_blank_html_page(step):
|
||||
world.create_component_instance(
|
||||
step,
|
||||
'.large-html-icon',
|
||||
'html',
|
||||
'.xmodule_HtmlModule',
|
||||
'latex_html.yaml'
|
||||
)
|
||||
|
||||
@@ -47,12 +47,12 @@ Feature: Problem Editor
|
||||
Scenario: User cannot type decimal values integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234"
|
||||
Then if I set the max attempts to "2.34", it will persist as a valid integer
|
||||
|
||||
Scenario: User cannot type out of range values in an integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0"
|
||||
Then if I set the max attempts to "-3", it will persist as a valid integer
|
||||
|
||||
Scenario: Settings changes are not saved on Cancel
|
||||
Given I have created a Blank Common Problem
|
||||
@@ -66,6 +66,7 @@ Feature: Problem Editor
|
||||
When I edit and select Settings
|
||||
Then Edit High Level Source is visible
|
||||
|
||||
# This feature will work in Firefox only when Firefox is the active window
|
||||
Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
|
||||
Given I have created a LaTeX Problem
|
||||
When I edit and compile the High Level Source
|
||||
|
||||
@@ -45,7 +45,10 @@ def i_see_five_settings_with_values(step):
|
||||
def i_can_modify_the_display_name(step):
|
||||
# Verifying that the display name can be a string containing a floating point value
|
||||
# (to confirm that we don't throw an error because it is of the wrong type).
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('3.4')
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.css_fill('.wrapper-comp-setting .setting-input', '3.4', index=index)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('.wrapper-comp-setting .setting-input', index=index)
|
||||
verify_modified_display_name()
|
||||
|
||||
|
||||
@@ -57,7 +60,10 @@ def my_display_name_change_is_persisted_on_save(step):
|
||||
|
||||
@step('I can specify special characters in the display name')
|
||||
def i_can_modify_the_display_name_with_special_chars(step):
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill("updated ' \" &")
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.css_fill('.wrapper-comp-setting .setting-input', "updated ' \" &", index=index)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('.wrapper-comp-setting .setting-input', index=index)
|
||||
verify_modified_display_name_with_special_chars()
|
||||
|
||||
|
||||
@@ -127,12 +133,16 @@ def set_the_weight_to_abc(step, bad_weight):
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False)
|
||||
|
||||
|
||||
@step('if I set the max attempts to "(.*)", it displays initially as "(.*)", and is persisted as "(.*)"')
|
||||
def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted):
|
||||
world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set)
|
||||
world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True)
|
||||
@step('if I set the max attempts to "(.*)", it will persist as a valid integer$')
|
||||
def set_the_max_attempts(step, max_attempts_set):
|
||||
# on firefox with selenium, the behaviour is different. eg 2.34 displays as 2.34 and is persisted as 2
|
||||
index = world.get_setting_entry_index(MAXIMUM_ATTEMPTS)
|
||||
world.css_fill('.wrapper-comp-setting .setting-input', max_attempts_set, index=index)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('.wrapper-comp-setting .setting-input', index=index)
|
||||
world.save_component_and_reopen(step)
|
||||
world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_persisted, True)
|
||||
value = int(world.css_value('input.setting-input', index=index))
|
||||
assert value >= 0
|
||||
|
||||
|
||||
@step('Edit High Level Source is not visible')
|
||||
@@ -155,6 +165,10 @@ def cancel_does_not_save_changes(step):
|
||||
@step('I have created a LaTeX Problem')
|
||||
def create_latex_problem(step):
|
||||
world.click_new_component_button(step, '.large-problem-icon')
|
||||
|
||||
def animation_done(_driver):
|
||||
return world.browser.evaluate_script("$('div.new-component').css('display')") == 'none'
|
||||
world.wait_for(animation_done)
|
||||
# Go to advanced tab.
|
||||
world.css_click('#ui-id-2')
|
||||
world.click_component_from_menu("problem", "latex_problem.yaml", '.xmodule_CapaModule')
|
||||
@@ -209,7 +223,11 @@ def verify_unset_display_name():
|
||||
|
||||
|
||||
def set_weight(weight):
|
||||
world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight)
|
||||
index = world.get_setting_entry_index(PROBLEM_WEIGHT)
|
||||
world.css_fill('.wrapper-comp-setting .setting-input', weight, index=index)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('.wrapper-comp-setting .setting-input', index=index, event='blur')
|
||||
world.trigger_event('a.save-button', event='focus')
|
||||
|
||||
|
||||
def open_high_level_source():
|
||||
|
||||
@@ -3,7 +3,6 @@ Feature: Create Section
|
||||
As a course author
|
||||
I want to create and edit sections
|
||||
|
||||
@skip
|
||||
Scenario: Add a new section to a course
|
||||
Given I have opened a new course in Studio
|
||||
When I click the New Section link
|
||||
@@ -24,7 +23,7 @@ Feature: Create Section
|
||||
Given I have opened a new course in Studio
|
||||
And I have added a new section
|
||||
When I click the Edit link for the release date
|
||||
And I save a new section release date
|
||||
And I set the section release date to 12/25/2013
|
||||
Then the section release date is updated
|
||||
And I see a "saving" notification
|
||||
|
||||
|
||||
@@ -35,15 +35,20 @@ def i_click_the_edit_link_for_the_release_date(_step):
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step('I save a new section release date$')
|
||||
def i_save_a_new_section_release_date(_step):
|
||||
set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013',
|
||||
'input.start-time.time.ui-timepicker-input', '00:00')
|
||||
@step('I set the section release date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_section_release_date(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
if not timestring:
|
||||
timestring = "00:00"
|
||||
set_date_and_time(
|
||||
'input.start-date.date.hasDatepicker', datestring,
|
||||
'input.start-time.time.ui-timepicker-input', timestring)
|
||||
world.browser.click_link_by_text('Save')
|
||||
|
||||
|
||||
@step('I see a "saving" notification')
|
||||
def i_see_a_saving_notification(step):
|
||||
@step('I see a "(saving|deleting)" notification')
|
||||
def i_see_a_mini_notification(_step, _type):
|
||||
saving_css = '.wrapper-notification-mini'
|
||||
assert world.is_css_present(saving_css)
|
||||
|
||||
|
||||
@@ -8,5 +8,21 @@ Feature: Sign in
|
||||
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
|
||||
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."
|
||||
Then I should see an email verification prompt
|
||||
|
||||
Scenario: Login with a valid redirect
|
||||
Given I have opened a new course in Studio
|
||||
And I am not logged in
|
||||
And I visit the url "/MITx/999/course/Robot_Super_Course"
|
||||
And I should see that the path is "/signin?next=/MITx/999/course/Robot_Super_Course"
|
||||
When I fill in and submit the signin form
|
||||
And I wait for "2" seconds
|
||||
Then I should see that the path is "/MITx/999/course/Robot_Super_Course"
|
||||
|
||||
Scenario: Login with an invalid redirect
|
||||
Given I have opened a new course in Studio
|
||||
And I am not logged in
|
||||
And I visit the url "/signin?next=http://www.google.com/"
|
||||
When I fill in and submit the signin form
|
||||
And I wait for "2" seconds
|
||||
Then I should see that the path is "/"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
|
||||
|
||||
@step('I fill in the registration form$')
|
||||
@@ -23,11 +22,17 @@ def i_press_the_button_on_the_registration_form(step):
|
||||
world.css_click(submit_css)
|
||||
|
||||
|
||||
@step('I should see be on the studio home page$')
|
||||
def i_should_see_be_on_the_studio_home_page(step):
|
||||
assert world.browser.find_by_css('div.inner-wrapper')
|
||||
@step('I should see an email verification prompt')
|
||||
def i_should_see_an_email_verification_prompt(step):
|
||||
world.css_has_text('h1.page-header', u'My Courses')
|
||||
world.css_has_text('div.msg h3.title', u'We need to verify your email address')
|
||||
|
||||
|
||||
@step(u'I should see the message "([^"]*)"$')
|
||||
def i_should_see_the_message(step, msg):
|
||||
assert world.browser.is_text_present(msg, 5)
|
||||
@step(u'I fill in and submit the signin form$')
|
||||
def i_fill_in_the_signin_form(step):
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
login_form.find_by_name('password').fill('test')
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
|
||||
@@ -8,7 +8,7 @@ from selenium.webdriver.common.keys import Keys
|
||||
@step(u'I go to the static pages page')
|
||||
def go_to_static(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
static_css = 'li.nav-course-courseware-pages'
|
||||
static_css = 'li.nav-course-courseware-pages a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(static_css)
|
||||
|
||||
@@ -38,14 +38,12 @@ def click_edit_delete(_step, edit_delete, page):
|
||||
|
||||
@step(u'I change the name to "([^"]*)"$')
|
||||
def change_name(_step, new_name):
|
||||
settings_css = '#settings-mode'
|
||||
settings_css = '#settings-mode a'
|
||||
world.css_click(settings_css)
|
||||
input_css = 'input.setting-input'
|
||||
name_input = world.css_find(input_css)
|
||||
old_name = name_input.value
|
||||
for count in range(len(old_name)):
|
||||
name_input._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
name_input._element.send_keys(new_name)
|
||||
world.css_fill(input_css, new_name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
save_button = 'a.save-button'
|
||||
world.css_click(save_button)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Feature: Create Subsection
|
||||
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
|
||||
And I click on the subsection
|
||||
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)
|
||||
@@ -27,10 +27,13 @@ Feature: Create Subsection
|
||||
|
||||
Scenario: Set a due date in a different year (bug #256)
|
||||
Given I have opened a new subsection in Studio
|
||||
And I have set a release date and due date in different years
|
||||
Then I see the correct dates
|
||||
And I set the subsection release date to 12/25/2011 03:00
|
||||
And I set the subsection due date to 01/02/2012 04:00
|
||||
Then I see the subsection release date is 12/25/2011 03:00
|
||||
And I see the subsection due date is 01/02/2012 04:00
|
||||
And I reload the page
|
||||
Then I see the correct dates
|
||||
Then I see the subsection release date is 12/25/2011 03:00
|
||||
And I see the subsection due date is 01/02/2012 04:00
|
||||
|
||||
Scenario: Delete a subsection
|
||||
Given I have opened a new course section in Studio
|
||||
@@ -40,3 +43,16 @@ Feature: Create Subsection
|
||||
And I press the "subsection" delete icon
|
||||
And I confirm the prompt
|
||||
Then the subsection does not exist
|
||||
|
||||
Scenario: Sync to Section
|
||||
Given I have opened a new course section in Studio
|
||||
And I click the Edit link for the release date
|
||||
And I set the section release date to 01/02/2103
|
||||
And I have added a new subsection
|
||||
And I click on the subsection
|
||||
And I set the subsection release date to 01/20/2103
|
||||
And I reload the page
|
||||
And I click the link to sync release date to section
|
||||
And I wait for "1" second
|
||||
And I reload the page
|
||||
Then I see the subsection release date is 01/02/2103
|
||||
|
||||
@@ -41,8 +41,8 @@ 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):
|
||||
@step('I click on the subsection$')
|
||||
def click_on_subsection(step):
|
||||
world.css_click('span.subsection-name-value')
|
||||
|
||||
|
||||
@@ -53,12 +53,28 @@ def i_see_complete_subsection_name_with_quote_in_editor(step):
|
||||
assert_equal(world.css_value(css), 'Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('I have set a release date and due date in different years$')
|
||||
def test_have_set_dates_in_different_years(step):
|
||||
set_date_and_time('input#start_date', '12/25/2011', 'input#start_time', '03:00')
|
||||
world.css_click('.set-date')
|
||||
# Use a year in the past so that current year will always be different.
|
||||
set_date_and_time('input#due_date', '01/02/2012', 'input#due_time', '04:00')
|
||||
@step('I set the subsection release date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_release_date(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
if not timestring:
|
||||
timestring = "00:00"
|
||||
set_date_and_time(
|
||||
'input#start_date', datestring,
|
||||
'input#start_time', timestring)
|
||||
|
||||
|
||||
@step('I set the subsection due date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_due_date(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
if not timestring:
|
||||
timestring = "00:00"
|
||||
if not world.css_visible('input#due_date'):
|
||||
world.css_click('.due-date-input .set-date')
|
||||
set_date_and_time(
|
||||
'input#due_date', datestring,
|
||||
'input#due_time', timestring)
|
||||
|
||||
|
||||
@step('I mark it as Homework$')
|
||||
@@ -72,6 +88,11 @@ def i_see_it_marked__as_homework(step):
|
||||
assert_equal(world.css_value(".status-label"), 'Homework')
|
||||
|
||||
|
||||
@step('I click the link to sync release date to section')
|
||||
def click_sync_release_date(step):
|
||||
world.css_click('.sync-date')
|
||||
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
|
||||
@@ -91,16 +112,25 @@ def the_subsection_does_not_exist(step):
|
||||
assert world.browser.is_element_not_present_by_css(css)
|
||||
|
||||
|
||||
@step('I see the correct dates$')
|
||||
def i_see_the_correct_dates(step):
|
||||
assert_equal('12/25/2011', get_date('input#start_date'))
|
||||
assert_equal('03:00', get_date('input#start_time'))
|
||||
assert_equal('01/02/2012', get_date('input#due_date'))
|
||||
assert_equal('04:00', get_date('input#due_time'))
|
||||
@step('I see the subsection release date is ([0-9/-]+)( [0-9:]+)?')
|
||||
def i_see_subsection_release(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
assert_equal(datestring, get_date('input#start_date'))
|
||||
if timestring:
|
||||
assert_equal(timestring, get_date('input#start_time'))
|
||||
|
||||
|
||||
@step('I see the subsection due date is ([0-9/-]+)( [0-9:]+)?')
|
||||
def i_see_subsection_due(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
assert_equal(datestring, get_date('input#due_date'))
|
||||
if timestring:
|
||||
assert_equal(timestring, get_date('input#due_time'))
|
||||
|
||||
|
||||
############ HELPER METHODS ###################
|
||||
|
||||
def get_date(css):
|
||||
return world.css_find(css).first.value.strip()
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
@step(u'I go to the textbooks page')
|
||||
def go_to_uploads(_step):
|
||||
world.click_course_content()
|
||||
menu_css = 'li.nav-course-courseware-textbooks'
|
||||
world.css_find(menu_css).click()
|
||||
menu_css = 'li.nav-course-courseware-textbooks a'
|
||||
world.css_click(menu_css)
|
||||
|
||||
|
||||
@step(u'I should see a message telling me to create a new textbook')
|
||||
@@ -45,6 +45,8 @@ def click_new_textbook(_step, on):
|
||||
def name_textbook(_step, name):
|
||||
input_css = ".textbook input[name=textbook-name]"
|
||||
world.css_fill(input_css, name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
|
||||
|
||||
@step(u'I name the (first|second|third) chapter "([^"]*)"')
|
||||
@@ -52,6 +54,8 @@ def name_chapter(_step, ordinal, name):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1)
|
||||
world.css_fill(input_css, name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
|
||||
|
||||
@step(u'I type in "([^"]*)" for the (first|second|third) chapter asset')
|
||||
@@ -59,6 +63,8 @@ def asset_chapter(_step, name, ordinal):
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1)
|
||||
world.css_fill(input_css, name)
|
||||
if world.is_firefox():
|
||||
world.trigger_event(input_css)
|
||||
|
||||
|
||||
@step(u'I click the Upload Asset link for the (first|second|third) chapter')
|
||||
|
||||
@@ -9,13 +9,11 @@ import random
|
||||
import os
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
HTTP_PREFIX = "http://localhost:%s" % settings.LETTUCE_SERVER_PORT
|
||||
|
||||
|
||||
@step(u'I go to the files and uploads page')
|
||||
def go_to_uploads(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
uploads_css = 'li.nav-course-courseware-uploads'
|
||||
uploads_css = 'li.nav-course-courseware-uploads a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(uploads_css)
|
||||
|
||||
@@ -24,13 +22,10 @@ def go_to_uploads(_step):
|
||||
def upload_file(_step, file_name):
|
||||
upload_css = 'a.upload-button'
|
||||
world.css_click(upload_css)
|
||||
|
||||
file_css = 'input.file-input'
|
||||
upload = world.css_find(file_css)
|
||||
#uploading the file itself
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
|
||||
upload._element.send_keys(os.path.abspath(path))
|
||||
|
||||
world.browser.execute_script("$('input.file-input').css('display', 'block')")
|
||||
world.browser.attach_file('file', os.path.abspath(path))
|
||||
close_css = 'a.close-button'
|
||||
world.css_click(close_css)
|
||||
|
||||
@@ -58,7 +53,7 @@ def delete_file(_step, file_name):
|
||||
world.css_click(delete_css, index=index)
|
||||
|
||||
prompt_confirm_css = 'li.nav-item > a.action-primary'
|
||||
world.css_click(prompt_confirm_css)
|
||||
world.css_click(prompt_confirm_css, success_condition=lambda: not world.css_visible(prompt_confirm_css))
|
||||
|
||||
|
||||
@step(u'I should see only one "([^"]*)"$')
|
||||
@@ -80,6 +75,9 @@ def check_download(_step, file_name):
|
||||
r = get_file(file_name)
|
||||
downloaded_text = r.text
|
||||
assert cur_text == downloaded_text
|
||||
#resetting the file back to its original state
|
||||
with open(os.path.abspath(path), 'w') as cur_file:
|
||||
cur_file.write("This is an arbitrary file for testing uploads")
|
||||
|
||||
|
||||
@step(u'I modify "([^"]*)"$')
|
||||
@@ -109,6 +107,8 @@ def get_file(file_name):
|
||||
index = get_index(file_name)
|
||||
assert index != -1
|
||||
|
||||
url_css = 'input.embeddable-xml-input'
|
||||
url = world.css_find(url_css)[index].value
|
||||
return requests.get(HTTP_PREFIX + url)
|
||||
url_css = 'a.filename'
|
||||
def get_url():
|
||||
return world.css_find(url_css)[index]._element.get_attribute('href')
|
||||
url = world.retry_on_exception(get_url)
|
||||
return requests.get(url)
|
||||
|
||||
@@ -19,5 +19,22 @@ def i_see_the_correct_settings_and_values(step):
|
||||
@step('I have set "show captions" to (.*)')
|
||||
def set_show_captions(step, setting):
|
||||
world.css_click('a.edit-button')
|
||||
world.wait_for(lambda _driver: world.css_visible('a.save-button'))
|
||||
world.browser.select('Show Captions', setting)
|
||||
world.css_click('a.save-button')
|
||||
|
||||
|
||||
@step('I see the correct videoalpha settings and default values$')
|
||||
def correct_videoalpha_settings(_step):
|
||||
world.verify_all_setting_entries([['Display Name', 'Video Alpha', False],
|
||||
['Download Track', '', False],
|
||||
['Download Video', '', False],
|
||||
['End Time', '0', False],
|
||||
['HTML5 Subtitles', '', False],
|
||||
['Show Captions', 'True', False],
|
||||
['Start Time', '0', False],
|
||||
['Video Sources', '', False],
|
||||
['Youtube ID', 'OEoXaMPEzfM', False],
|
||||
['Youtube ID for .75x speed', '', False],
|
||||
['Youtube ID for 1.25x speed', '', False],
|
||||
['Youtube ID for 1.5x speed', '', False]])
|
||||
|
||||
@@ -22,3 +22,33 @@ Feature: Video Component
|
||||
Given I have created a Video component
|
||||
And I have toggled captions
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
# Video Alpha Features will work in Firefox only when Firefox is the active window
|
||||
Scenario: Autoplay is disabled in Studio for Video Alpha
|
||||
Given I have created a Video Alpha component
|
||||
Then when I view the videoalpha it does not have autoplay enabled
|
||||
|
||||
Scenario: User can view Video Alpha metadata
|
||||
Given I have created a Video Alpha component
|
||||
And I edit the component
|
||||
Then I see the correct videoalpha settings and default values
|
||||
|
||||
Scenario: User can modify Video Alpha display name
|
||||
Given I have created a Video Alpha component
|
||||
And I edit the component
|
||||
Then I can modify the display name
|
||||
And my videoalpha display name change is persisted on save
|
||||
|
||||
Scenario: Video Alpha captions are hidden when "show captions" is false
|
||||
Given I have created a Video Alpha component
|
||||
And I have set "show captions" to False
|
||||
Then when I view the videoalpha it does not show the captions
|
||||
|
||||
Scenario: Video Alpha captions are shown when "show captions" is true
|
||||
Given I have created a Video Alpha component
|
||||
And I have set "show captions" to True
|
||||
Then when I view the videoalpha it does show the captions
|
||||
|
||||
Scenario: Video data is shown correctly
|
||||
Given I have created a video with only XML data
|
||||
Then the correct Youtube video is shown
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
#pylint: disable=C0111
|
||||
|
||||
from lettuce import world, step
|
||||
from terrain.steps import reload_the_page
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
|
||||
@step('when I view the video it does not have autoplay enabled')
|
||||
def does_not_autoplay(_step):
|
||||
assert world.css_find('.video')[0]['data-autoplay'] == 'False'
|
||||
@step('when I view the (.*) it does not have autoplay enabled')
|
||||
def does_not_autoplay(_step, video_type):
|
||||
assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False'
|
||||
assert world.css_has_class('.video_control', 'play')
|
||||
|
||||
|
||||
@@ -29,5 +33,55 @@ def hide_or_show_captions(step, shown):
|
||||
# click the button rather than the tooltip, so move the mouse
|
||||
# away to make it disappear.
|
||||
button = world.css_find(button_css)
|
||||
button.mouse_out()
|
||||
# mouse_out is not implemented on firefox with selenium
|
||||
if not world.is_firefox:
|
||||
button.mouse_out()
|
||||
world.css_click(button_css)
|
||||
|
||||
@step('I edit the component')
|
||||
def i_edit_the_component(_step):
|
||||
world.edit_component()
|
||||
|
||||
|
||||
@step('my videoalpha display name change is persisted on save')
|
||||
def videoalpha_name_persisted(step):
|
||||
world.css_click('a.save-button')
|
||||
reload_the_page(step)
|
||||
world.edit_component()
|
||||
world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True)
|
||||
|
||||
|
||||
@step('I have created a video with only XML data')
|
||||
def xml_only_video(step):
|
||||
# Create a new video *without* metadata. This requires a certain
|
||||
# amount of rummaging to make sure all the correct data is present
|
||||
step.given('I have clicked the new unit button')
|
||||
|
||||
# Wait for the new unit to be created and to load the page
|
||||
world.wait(1)
|
||||
|
||||
location = world.scenario_dict['COURSE'].location
|
||||
store = get_modulestore(location)
|
||||
|
||||
parent_location = store.get_items(Location(category='vertical', revision='draft'))[0].location
|
||||
|
||||
youtube_id = 'ABCDEFG'
|
||||
world.scenario_dict['YOUTUBE_ID'] = youtube_id
|
||||
|
||||
# Create a new Video component, but ensure that it doesn't have
|
||||
# metadata. This allows us to test that we are correctly parsing
|
||||
# out XML
|
||||
video = world.ItemFactory.create(
|
||||
parent_location=parent_location,
|
||||
category='video',
|
||||
data='<video youtube="1.00:%s"></video>' % youtube_id
|
||||
)
|
||||
|
||||
# Refresh to see the new video
|
||||
reload_the_page(step)
|
||||
|
||||
|
||||
@step('The correct Youtube video is shown')
|
||||
def the_youtube_video_is_shown(_step):
|
||||
ele = world.css_find('.video').first
|
||||
assert ele['data-youtube-id-1-0'] == world.scenario_dict['YOUTUBE_ID']
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
###
|
||||
### Script for cloning a course
|
||||
###
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.store_utilities import clone_course
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
from auth.authz import _copy_course_group
|
||||
|
||||
#
|
||||
# To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3
|
||||
#
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clone a MongoDB backed course to another location'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 2:
|
||||
raise CommandError("clone requires two arguments: <source-location> <dest-location>")
|
||||
|
||||
source_location_str = args[0]
|
||||
dest_location_str = args[1]
|
||||
|
||||
ms = modulestore('direct')
|
||||
cs = contentstore()
|
||||
|
||||
print "Cloning course {0} to {1}".format(source_location_str, dest_location_str)
|
||||
|
||||
source_location = CourseDescriptor.id_to_location(source_location_str)
|
||||
dest_location = CourseDescriptor.id_to_location(dest_location_str)
|
||||
|
||||
if clone_course(ms, cs, source_location, dest_location):
|
||||
print "copying User permissions..."
|
||||
_copy_course_group(source_location, dest_location)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Script for cloning a course
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.store_utilities import clone_course
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
from auth.authz import _copy_course_group
|
||||
|
||||
#
|
||||
# To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3
|
||||
#
|
||||
from request_cache.middleware import RequestCache
|
||||
from django.core.cache import get_cache
|
||||
|
||||
#
|
||||
# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
|
||||
#
|
||||
|
||||
CACHE = get_cache('mongo_metadata_inheritance')
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Clone a MongoDB-backed course to another location"""
|
||||
help = 'Clone a MongoDB backed course to another location'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) != 2:
|
||||
raise CommandError("clone requires two arguments: <source-course_id> <dest-course_id>")
|
||||
|
||||
source_course_id = args[0]
|
||||
dest_course_id = args[1]
|
||||
|
||||
mstore = modulestore('direct')
|
||||
cstore = contentstore()
|
||||
|
||||
mstore.metadata_inheritance_cache_subsystem = CACHE
|
||||
mstore.request_cache = RequestCache.get_request_cache()
|
||||
org, course_num, run = dest_course_id.split("/")
|
||||
mstore.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
|
||||
|
||||
print("Cloning course {0} to {1}".format(source_course_id, dest_course_id))
|
||||
|
||||
source_location = CourseDescriptor.id_to_location(source_course_id)
|
||||
dest_location = CourseDescriptor.id_to_location(dest_course_id)
|
||||
|
||||
if clone_course(mstore, cstore, source_location, dest_location):
|
||||
# be sure to recompute metadata inheritance after all those updates
|
||||
mstore.refresh_cached_metadata_inheritance_tree(dest_location)
|
||||
|
||||
print("copying User permissions...")
|
||||
_copy_course_group(source_location, dest_location)
|
||||
@@ -9,12 +9,14 @@ from xmodule.course_module import CourseDescriptor
|
||||
from .prompt import query_yes_no
|
||||
|
||||
from auth.authz import _delete_course_group
|
||||
from request_cache.middleware import RequestCache
|
||||
from django.core.cache import get_cache
|
||||
|
||||
#
|
||||
# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
|
||||
#
|
||||
|
||||
|
||||
CACHE = get_cache('mongo_metadata_inheritance')
|
||||
class Command(BaseCommand):
|
||||
help = '''Delete a MongoDB backed course'''
|
||||
|
||||
@@ -22,7 +24,7 @@ class Command(BaseCommand):
|
||||
if len(args) != 1 and len(args) != 2:
|
||||
raise CommandError("delete_course requires one or more arguments: <location> |commit|")
|
||||
|
||||
loc_str = args[0]
|
||||
course_id = args[0]
|
||||
|
||||
commit = False
|
||||
if len(args) == 2:
|
||||
@@ -34,9 +36,14 @@ class Command(BaseCommand):
|
||||
ms = modulestore('direct')
|
||||
cs = contentstore()
|
||||
|
||||
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
|
||||
ms.metadata_inheritance_cache_subsystem = CACHE
|
||||
ms.request_cache = RequestCache.get_request_cache()
|
||||
org, course_num, run = course_id.split("/")
|
||||
ms.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
|
||||
|
||||
if query_yes_no("Deleting course {0}. Confirm?".format(course_id), default="no"):
|
||||
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
|
||||
loc = CourseDescriptor.id_to_location(loc_str)
|
||||
loc = CourseDescriptor.id_to_location(course_id)
|
||||
if delete_course(ms, cs, loc, commit):
|
||||
print 'removing User permissions from course....'
|
||||
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
Script for dumping course dumping the course structure
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -9,10 +12,14 @@ filter_list = ['xml_attributes', 'checklists']
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
The Django command for dumping course structure
|
||||
"""
|
||||
help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized
|
||||
in a JSON format. This can be used for analytics.'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) < 2 or len(args) > 3:
|
||||
raise CommandError("dump_course_structure requires two or more arguments: <location> <outfile> |<db>|")
|
||||
|
||||
@@ -32,7 +39,7 @@ class Command(BaseCommand):
|
||||
try:
|
||||
course = store.get_item(loc, depth=4)
|
||||
except:
|
||||
print 'Could not find course at {0}'.format(course_id)
|
||||
print('Could not find course at {0}'.format(course_id))
|
||||
return
|
||||
|
||||
info = {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
###
|
||||
### Script for exporting courseware from Mongo to a tar.gz file
|
||||
###
|
||||
"""
|
||||
Script for exporting courseware from Mongo to a tar.gz file
|
||||
"""
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
@@ -10,20 +10,21 @@ from xmodule.contentstore.django import contentstore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
|
||||
unnamed_modules = 0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Export the specified data directory into the default ModuleStore
|
||||
"""
|
||||
help = 'Export the specified data directory into the default ModuleStore'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) != 2:
|
||||
raise CommandError("export requires two arguments: <course location> <output path>")
|
||||
|
||||
course_id = args[0]
|
||||
output_path = args[1]
|
||||
|
||||
print "Exporting course id = {0} to {1}".format(course_id, output_path)
|
||||
print("Exporting course id = {0} to {1}".format(course_id, output_path))
|
||||
|
||||
location = CourseDescriptor.id_to_location(course_id)
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
###
|
||||
### Script for exporting all courseware from Mongo to a directory
|
||||
###
|
||||
import os
|
||||
|
||||
"""
|
||||
Script for exporting all courseware from Mongo to a directory
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -10,13 +8,12 @@ from xmodule.contentstore.django import contentstore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
|
||||
unnamed_modules = 0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Export all courses from mongo to the specified data directory"""
|
||||
help = 'Export all courses from mongo to the specified data directory'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) != 1:
|
||||
raise CommandError("export requires one argument: <output path>")
|
||||
|
||||
@@ -27,14 +24,14 @@ class Command(BaseCommand):
|
||||
root_dir = output_path
|
||||
courses = ms.get_courses()
|
||||
|
||||
print "%d courses to export:" % len(courses)
|
||||
print("%d courses to export:" % len(courses))
|
||||
cids = [x.id for x in courses]
|
||||
print cids
|
||||
print(cids)
|
||||
|
||||
for course_id in cids:
|
||||
|
||||
print "-"*77
|
||||
print "Exporting course id = {0} to {1}".format(course_id, output_path)
|
||||
print("-"*77)
|
||||
print("Exporting course id = {0} to {1}".format(course_id, output_path))
|
||||
|
||||
if 1:
|
||||
try:
|
||||
@@ -42,6 +39,6 @@ class Command(BaseCommand):
|
||||
course_dir = course_id.replace('/', '...')
|
||||
export_to_xml(ms, cs, location, root_dir, course_dir, modulestore())
|
||||
except Exception as err:
|
||||
print "="*30 + "> Oops, failed to export %s" % course_id
|
||||
print "Error:"
|
||||
print err
|
||||
print("="*30 + "> Oops, failed to export %s" % course_id)
|
||||
print("Error:")
|
||||
print(err)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
###
|
||||
### Script for importing courseware from XML format
|
||||
###
|
||||
"""
|
||||
Script for importing courseware from XML format
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
@@ -8,13 +8,14 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
|
||||
|
||||
unnamed_modules = 0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Import the specified data directory into the default ModuleStore
|
||||
"""
|
||||
help = 'Import the specified data directory into the default ModuleStore'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) == 0:
|
||||
raise CommandError("import requires at least one argument: <data directory> [<course dir>...]")
|
||||
|
||||
@@ -23,8 +24,8 @@ class Command(BaseCommand):
|
||||
course_dirs = args[1:]
|
||||
else:
|
||||
course_dirs = None
|
||||
print "Importing. Data_dir={data}, course_dirs={courses}".format(
|
||||
print("Importing. Data_dir={data}, course_dirs={courses}".format(
|
||||
data=data_dir,
|
||||
courses=course_dirs)
|
||||
courses=course_dirs))
|
||||
import_from_xml(modulestore('direct'), data_dir, course_dirs, load_error_modules=False,
|
||||
static_content_store=contentstore(), verbose=True)
|
||||
|
||||
@@ -39,7 +39,7 @@ class Command(BaseCommand):
|
||||
# added with status granted above, and add_user_with_status_unrequested
|
||||
# will not try to add them again if they already exist in the course creator database.
|
||||
for user in get_users_with_staff_role():
|
||||
add_user_with_status_unrequested(admin, user)
|
||||
add_user_with_status_unrequested(user)
|
||||
|
||||
# There could be users who are not in either staff or instructor (they've
|
||||
# never actually done anything in Studio). I plan to add those as unrequested
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"""
|
||||
Verify the structure of courseware as to it's suitability for import
|
||||
To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)]
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.modulestore.xml_importer import perform_xlint
|
||||
|
||||
|
||||
unnamed_modules = 0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = \
|
||||
'''
|
||||
Verify the structure of courseware as to it's suitability for import
|
||||
To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)]
|
||||
'''
|
||||
"""Verify the structure of courseware as to it's suitability for import"""
|
||||
help = "Verify the structure of courseware as to it's suitability for import"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) == 0:
|
||||
raise CommandError("import requires at least one argument: <data directory> [<course dir>...]")
|
||||
|
||||
@@ -21,7 +20,7 @@ class Command(BaseCommand):
|
||||
course_dirs = args[1:]
|
||||
else:
|
||||
course_dirs = None
|
||||
print "Importing. Data_dir={data}, course_dirs={courses}".format(
|
||||
print("Importing. Data_dir={data}, course_dirs={courses}".format(
|
||||
data=data_dir,
|
||||
courses=course_dirs)
|
||||
courses=course_dirs))
|
||||
perform_xlint(data_dir, course_dirs, load_error_modules=False)
|
||||
|
||||
@@ -10,6 +10,8 @@ from unittest import TestCase, skip
|
||||
from .utils import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
from contentstore.views import assets
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
|
||||
class AssetsTestCase(CourseTestCase):
|
||||
@@ -35,6 +37,11 @@ class AssetsTestCase(CourseTestCase):
|
||||
content = json.loads(resp.content)
|
||||
self.assertIsInstance(content, list)
|
||||
|
||||
def test_static_url_generation(self):
|
||||
location = Location(['i4x', 'foo', 'bar', 'asset', 'my_file_name.jpg'])
|
||||
path = StaticContent.get_static_path_from_location(location)
|
||||
self.assertEquals(path, '/static/my_file_name.jpg')
|
||||
|
||||
|
||||
class UploadTestCase(CourseTestCase):
|
||||
"""
|
||||
@@ -50,9 +57,9 @@ class UploadTestCase(CourseTestCase):
|
||||
|
||||
@skip("CorruptGridFile error on continuous integration server")
|
||||
def test_happy_path(self):
|
||||
file = BytesIO("sample content")
|
||||
file.name = "sample.txt"
|
||||
resp = self.client.post(self.url, {"name": "my-name", "file": file})
|
||||
f = BytesIO("sample content")
|
||||
f.name = "sample.txt"
|
||||
resp = self.client.post(self.url, {"name": "my-name", "file": f})
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
def test_no_file(self):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
""" Unit tests for checklist methods in views.py. """
|
||||
from contentstore.utils import get_modulestore, get_url_reverse
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
@@ -27,6 +27,7 @@ class ChecklistTestCase(CourseTestCase):
|
||||
"""
|
||||
self.assertEqual(persisted['short_description'], request['short_description'])
|
||||
compare_urls = (persisted.get('action_urls_expanded') == request.get('action_urls_expanded'))
|
||||
pers, req = None, None
|
||||
for pers, req in zip(persisted['items'], request['items']):
|
||||
self.assertEqual(pers['short_description'], req['short_description'])
|
||||
self.assertEqual(pers['long_description'], req['long_description'])
|
||||
@@ -38,7 +39,11 @@ class ChecklistTestCase(CourseTestCase):
|
||||
|
||||
def test_get_checklists(self):
|
||||
""" Tests the get checklists method. """
|
||||
checklists_url = get_url_reverse('Checklists', self.course)
|
||||
checklists_url = reverse("checklists", kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
})
|
||||
response = self.client.get(checklists_url)
|
||||
self.assertContains(response, "Getting Started With Studio")
|
||||
payload = response.content
|
||||
|
||||
@@ -49,7 +49,7 @@ import datetime
|
||||
from pytz import UTC
|
||||
from uuid import uuid4
|
||||
from pymongo import MongoClient
|
||||
|
||||
from student.views import is_enrolled_in_course
|
||||
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
@@ -95,8 +95,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
def tearDown(self):
|
||||
mongo = MongoClient()
|
||||
mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db'])
|
||||
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db'])
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
def check_components_on_page(self, component_types, expected_types):
|
||||
@@ -304,6 +303,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
num_drafts = self._get_draft_counts(course)
|
||||
self.assertEqual(num_drafts, 1)
|
||||
|
||||
def test_no_static_link_rewrites_on_import(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course_info', 'handouts', None]))
|
||||
self.assertIn('/static/', handouts.data)
|
||||
|
||||
handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'html', 'toyhtml', None]))
|
||||
self.assertIn('/static/', handouts.data)
|
||||
|
||||
def test_import_textbook_as_content_element(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
@@ -604,6 +613,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
'run': '2013_Spring'
|
||||
}
|
||||
|
||||
module_store = modulestore('direct')
|
||||
@@ -612,12 +622,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
resp = self.client.post(reverse('create_new_course'), course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
|
||||
self.assertEqual(data['id'], 'i4x://MITx/999/course/2013_Spring')
|
||||
|
||||
content_store = contentstore()
|
||||
|
||||
source_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course')
|
||||
dest_location = CourseDescriptor.id_to_location('MITx/999/2013_Spring')
|
||||
|
||||
clone_course(module_store, content_store, source_location, dest_location)
|
||||
|
||||
@@ -855,6 +865,68 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def test_export_course_with_metadata_only_word_cloud(self):
|
||||
"""
|
||||
Similar to `test_export_course_with_metadata_only_video`.
|
||||
"""
|
||||
module_store = modulestore('direct')
|
||||
draft_store = modulestore('draft')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['word_cloud'])
|
||||
location = CourseDescriptor.id_to_location('HarvardX/ER22x/2013_Spring')
|
||||
|
||||
verticals = module_store.get_items(['i4x', 'HarvardX', 'ER22x', 'vertical', None, None])
|
||||
|
||||
self.assertGreater(len(verticals), 0)
|
||||
|
||||
parent = verticals[0]
|
||||
|
||||
ItemFactory.create(parent_location=parent.location, category="word_cloud", display_name="untitled")
|
||||
|
||||
root_dir = path(mkdtemp_clean())
|
||||
|
||||
print 'Exporting to tempdir = {0}'.format(root_dir)
|
||||
|
||||
# export out to a tempdir
|
||||
export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store)
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def test_empty_data_roundtrip(self):
|
||||
"""
|
||||
Test that an empty `data` field is preserved through
|
||||
export/import.
|
||||
"""
|
||||
module_store = modulestore('direct')
|
||||
draft_store = modulestore('draft')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
|
||||
verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None])
|
||||
|
||||
self.assertGreater(len(verticals), 0)
|
||||
|
||||
parent = verticals[0]
|
||||
|
||||
# Create a module, and ensure that its `data` field is empty
|
||||
word_cloud = ItemFactory.create(parent_location=parent.location, category="word_cloud", display_name="untitled")
|
||||
del word_cloud.data
|
||||
self.assertEquals(word_cloud.data, '')
|
||||
|
||||
# Export the course
|
||||
root_dir = path(mkdtemp_clean())
|
||||
export_to_xml(module_store, content_store, location, root_dir, 'test_roundtrip', draft_modulestore=draft_store)
|
||||
|
||||
# Reimport and get the video back
|
||||
import_from_xml(module_store, root_dir)
|
||||
imported_word_cloud = module_store.get_item(Location(['i4x', 'edX', 'toy', 'word_cloud', 'untitled', None]))
|
||||
|
||||
# It should now contain empty data
|
||||
self.assertEquals(imported_word_cloud.data, '')
|
||||
|
||||
def test_course_handouts_rewrites(self):
|
||||
module_store = modulestore('direct')
|
||||
|
||||
@@ -954,6 +1026,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
'run': '2013_Spring'
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
@@ -965,40 +1038,58 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
"""Test new course creation - happy path"""
|
||||
self.assert_created_course()
|
||||
|
||||
def assert_created_course(self):
|
||||
def assert_created_course(self, number_suffix=None):
|
||||
"""
|
||||
Checks that the course was created properly.
|
||||
"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
test_course_data = {}
|
||||
test_course_data.update(self.course_data)
|
||||
if number_suffix:
|
||||
test_course_data['number'] = '{0}_{1}'.format(test_course_data['number'], number_suffix)
|
||||
resp = self.client.post(reverse('create_new_course'), test_course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
|
||||
self.assertNotIn('ErrMsg', data)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/{0}/course/2013_Spring'.format(test_course_data['number']))
|
||||
# Verify that the creator is now registered in the course.
|
||||
self.assertTrue(is_enrolled_in_course(self.user, self._get_course_id(test_course_data)))
|
||||
return test_course_data
|
||||
|
||||
def test_create_course_check_forum_seeding(self):
|
||||
"""Test new course creation and verify forum seeding """
|
||||
self.assert_created_course()
|
||||
self.assertTrue(are_permissions_roles_seeded('MITx/999/Robot_Super_Course'))
|
||||
test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
|
||||
self.assertTrue(are_permissions_roles_seeded(self._get_course_id(test_course_data)))
|
||||
|
||||
def _get_course_id(self, test_course_data):
|
||||
"""Returns the course ID (org/number/run)."""
|
||||
return "{org}/{number}/{run}".format(**test_course_data)
|
||||
|
||||
def test_create_course_duplicate_course(self):
|
||||
"""Test new course creation - error path"""
|
||||
self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.assert_course_creation_failed('There is already a course defined with this name.')
|
||||
self.assert_course_creation_failed('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.')
|
||||
|
||||
def assert_course_creation_failed(self, error_message):
|
||||
"""
|
||||
Checks that the course did not get created
|
||||
"""
|
||||
course_id = self._get_course_id(self.course_data)
|
||||
initially_enrolled = is_enrolled_in_course(self.user, course_id)
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['ErrMsg'], error_message)
|
||||
# One test case involves trying to create the same course twice. Hence for that course,
|
||||
# the user will be enrolled. In the other cases, initially_enrolled will be False.
|
||||
self.assertEqual(initially_enrolled, is_enrolled_in_course(self.user, course_id))
|
||||
|
||||
def test_create_course_duplicate_number(self):
|
||||
"""Test new course creation - error path"""
|
||||
self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.course_data['display_name'] = 'Robot Super Course Two'
|
||||
self.course_data['run'] = '2013_Summer'
|
||||
|
||||
self.assert_course_creation_failed('There is already a course defined with the same organization and course number.')
|
||||
self.assert_course_creation_failed('There is already a course defined with the same organization and course number. Please change at least one field to be unique.')
|
||||
|
||||
def test_create_course_with_bad_organization(self):
|
||||
"""Test new course creation - error path for bad organization name"""
|
||||
@@ -1071,7 +1162,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
resp = self.client.get(reverse('index'))
|
||||
self.assertContains(
|
||||
resp,
|
||||
'<span class="class-name">Robot Super Educational Course</span>',
|
||||
'<h3 class="course-title">Robot Super Educational Course</h3>',
|
||||
status_code=200,
|
||||
html=True
|
||||
)
|
||||
@@ -1167,7 +1258,9 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
# manage users
|
||||
resp = self.client.get(reverse('manage_users',
|
||||
kwargs={'location': loc.url()}))
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
# course info
|
||||
@@ -1359,3 +1452,63 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
self.assertEqual(course.textbooks, fetched_course.textbooks)
|
||||
# is this test too strict? i.e., it requires the dicts to be ==
|
||||
self.assertEqual(course.checklists, fetched_course.checklists)
|
||||
|
||||
|
||||
class MetadataSaveTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Test that metadata is correctly decached.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
sample_xml = '''
|
||||
<video display_name="Test Video"
|
||||
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
|
||||
show_captions="false"
|
||||
from="00:00:01"
|
||||
to="00:01:00">
|
||||
<source src="http://www.example.com/file.mp4"/>
|
||||
<track src="http://www.example.com/track"/>
|
||||
</video>
|
||||
'''
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
model_data = {'data': sample_xml}
|
||||
self.descriptor = ItemFactory.create(parent_location=course_location, category='video', data=model_data)
|
||||
|
||||
def test_metadata_persistence(self):
|
||||
"""
|
||||
Test that descriptors which set metadata fields in their
|
||||
constructor are correctly persisted.
|
||||
"""
|
||||
# We should start with a source field, from the XML's <source/> tag
|
||||
self.assertIn('source', own_metadata(self.descriptor))
|
||||
attrs_to_strip = {
|
||||
'show_captions',
|
||||
'youtube_id_1_0',
|
||||
'youtube_id_0_75',
|
||||
'youtube_id_1_25',
|
||||
'youtube_id_1_5',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'source',
|
||||
'track'
|
||||
}
|
||||
# We strip out all metadata fields to reproduce a bug where
|
||||
# constructors which set their fields (e.g. Video) didn't have
|
||||
# those changes persisted. So in the end we have the XML data
|
||||
# in `descriptor.data`, but not in the individual fields
|
||||
fields = self.descriptor.fields
|
||||
for field in fields:
|
||||
if field.name in attrs_to_strip:
|
||||
field.delete_from(self.descriptor)
|
||||
|
||||
# Assert that we correctly stripped the field
|
||||
self.assertNotIn('source', own_metadata(self.descriptor))
|
||||
get_modulestore(self.descriptor.location).update_metadata(
|
||||
self.descriptor.location,
|
||||
own_metadata(self.descriptor)
|
||||
)
|
||||
module = get_modulestore(self.descriptor.location).get_item(self.descriptor.location)
|
||||
# Assert that get_item correctly sets the metadata
|
||||
self.assertIn('source', own_metadata(module))
|
||||
|
||||
@@ -18,8 +18,6 @@ from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.fields import Date
|
||||
|
||||
from .utils import CourseTestCase
|
||||
@@ -167,8 +165,8 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val))
|
||||
|
||||
@staticmethod
|
||||
def convert_datetime_to_iso(dt):
|
||||
return Date().to_json(dt)
|
||||
def convert_datetime_to_iso(datetime_obj):
|
||||
return Date().to_json(datetime_obj)
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
loc = self.course.location
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
import json
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class CourseUpdateTest(CourseTestCase):
|
||||
@@ -145,3 +146,36 @@ class CourseUpdateTest(CourseTestCase):
|
||||
resp = self.client.delete(url)
|
||||
payload = json.loads(resp.content)
|
||||
self.assertTrue(len(payload) == before_delete - 1)
|
||||
|
||||
def test_no_ol_course_update(self):
|
||||
'''Test trying to add to a saved course_update which is not an ol.'''
|
||||
# get the updates and set to something wrong
|
||||
location = self.course.location.replace(category='course_info', name='updates')
|
||||
modulestore('direct').create_and_save_xmodule(location)
|
||||
course_updates = modulestore('direct').get_item(location)
|
||||
course_updates.data = 'bad news'
|
||||
modulestore('direct').update_item(location, course_updates.data)
|
||||
|
||||
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_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(payload['content'], content)
|
||||
|
||||
# now confirm that the bad news and the iframe make up 2 updates
|
||||
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)
|
||||
|
||||
@@ -127,7 +127,7 @@ class TemplateTests(unittest.TestCase):
|
||||
persistent_factories.ItemFactory.create(display_name='chapter 1',
|
||||
parent_location=test_course.location)
|
||||
|
||||
id_locator = CourseLocator(course_id=test_course.location.course_id, revision='draft')
|
||||
id_locator = CourseLocator(course_id=test_course.location.course_id, branch='draft')
|
||||
guid_locator = CourseLocator(version_guid=test_course.location.version_guid)
|
||||
# verify it can be retireved by id
|
||||
self.assertIsInstance(modulestore('split').get_course(id_locator), CourseDescriptor)
|
||||
|
||||
@@ -85,9 +85,11 @@ class InternationalizationTest(ModuleStoreTestCase):
|
||||
HTTP_ACCEPT_LANGUAGE='fr'
|
||||
)
|
||||
|
||||
TEST_STRING = u'<h1 class="title-1">' \
|
||||
+ u'My \xc7\xf6\xfcrs\xe9s L#' \
|
||||
+ u'</h1>'
|
||||
TEST_STRING = (
|
||||
u'<h1 class="title-1">'
|
||||
u'My \xc7\xf6\xfcrs\xe9s L#'
|
||||
u'</h1>'
|
||||
)
|
||||
|
||||
self.assertContains(resp,
|
||||
TEST_STRING,
|
||||
|
||||
@@ -14,19 +14,26 @@ class DeleteItem(CourseTestCase):
|
||||
super(DeleteItem, self).setUp()
|
||||
self.course = CourseFactory.create(org='mitX', number='333', display_name='Dummy Course')
|
||||
|
||||
def testDeleteStaticPage(self):
|
||||
def test_delete_static_page(self):
|
||||
# Add static tab
|
||||
data = json.dumps({
|
||||
'parent_location': 'i4x://mitX/333/course/Dummy_Course',
|
||||
'category': 'static_tab'
|
||||
})
|
||||
|
||||
resp = self.client.post(reverse('create_item'), data,
|
||||
content_type="application/json")
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
data,
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore).
|
||||
resp = self.client.post(reverse('delete_item'), resp.content, "application/json")
|
||||
resp = self.client.post(
|
||||
reverse('delete_item'),
|
||||
resp.content,
|
||||
"application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
@@ -122,6 +129,7 @@ class TestCreateItem(CourseTestCase):
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
class TestEditItem(CourseTestCase):
|
||||
"""
|
||||
Test contentstore.views.item.save_item
|
||||
@@ -151,10 +159,10 @@ class TestEditItem(CourseTestCase):
|
||||
chap_location = self.response_id(resp)
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps(
|
||||
{'parent_location': chap_location,
|
||||
'category': 'sequential'
|
||||
}),
|
||||
json.dumps({
|
||||
'parent_location': chap_location,
|
||||
'category': 'sequential',
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.seq_location = self.response_id(resp)
|
||||
@@ -162,9 +170,10 @@ class TestEditItem(CourseTestCase):
|
||||
template_id = 'multiplechoice.yaml'
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps({'parent_location': self.seq_location,
|
||||
'category': 'problem',
|
||||
'boilerplate': template_id
|
||||
json.dumps({
|
||||
'parent_location': self.seq_location,
|
||||
'category': 'problem',
|
||||
'boilerplate': template_id,
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
@@ -195,7 +204,6 @@ class TestEditItem(CourseTestCase):
|
||||
problem = modulestore('draft').get_item(self.problems[0])
|
||||
self.assertEqual(problem.rerandomize, 'never')
|
||||
|
||||
|
||||
def test_null_field(self):
|
||||
"""
|
||||
Sending null in for a field 'deletes' it
|
||||
@@ -240,4 +248,3 @@ class TestEditItem(CourseTestCase):
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
|
||||
|
||||
|
||||
@@ -1,15 +1,384 @@
|
||||
"""
|
||||
Tests for contentstore/views/user.py.
|
||||
"""
|
||||
import json
|
||||
from .utils import CourseTestCase
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.core.urlresolvers import reverse
|
||||
from auth.authz import get_course_groupname_for_role
|
||||
from student.views import is_enrolled_in_course
|
||||
|
||||
|
||||
class UsersTestCase(CourseTestCase):
|
||||
def setUp(self):
|
||||
super(UsersTestCase, self).setUp()
|
||||
self.url = reverse("add_user", kwargs={"location": ""})
|
||||
self.ext_user = User.objects.create_user(
|
||||
"joe", "joe@comedycentral.com", "haha")
|
||||
self.ext_user.is_active = True
|
||||
self.ext_user.is_staff = False
|
||||
self.ext_user.save()
|
||||
self.inactive_user = User.objects.create_user(
|
||||
"carl", "carl@comedycentral.com", "haha")
|
||||
self.inactive_user.is_active = False
|
||||
self.inactive_user.is_staff = False
|
||||
self.inactive_user.save()
|
||||
|
||||
def test_empty(self):
|
||||
resp = self.client.post(self.url)
|
||||
self.index_url = reverse("manage_users", kwargs={
|
||||
"org": self.course.location.org,
|
||||
"course": self.course.location.course,
|
||||
"name": self.course.location.name,
|
||||
})
|
||||
self.detail_url = reverse("course_team_user", kwargs={
|
||||
"org": self.course.location.org,
|
||||
"course": self.course.location.course,
|
||||
"name": self.course.location.name,
|
||||
"email": self.ext_user.email,
|
||||
})
|
||||
self.inactive_detail_url = reverse("course_team_user", kwargs={
|
||||
"org": self.course.location.org,
|
||||
"course": self.course.location.course,
|
||||
"name": self.course.location.name,
|
||||
"email": self.inactive_user.email,
|
||||
})
|
||||
self.invalid_detail_url = reverse("course_team_user", kwargs={
|
||||
"org": self.course.location.org,
|
||||
"course": self.course.location.course,
|
||||
"name": self.course.location.name,
|
||||
"email": "nonexistent@user.com",
|
||||
})
|
||||
self.staff_groupname = get_course_groupname_for_role(self.course.location, "staff")
|
||||
self.inst_groupname = get_course_groupname_for_role(self.course.location, "instructor")
|
||||
|
||||
def test_index(self):
|
||||
resp = self.client.get(self.index_url)
|
||||
# ext_user is not currently a member of the course team, and so should
|
||||
# not show up on the page.
|
||||
self.assertNotContains(resp, self.ext_user.email)
|
||||
|
||||
def test_index_member(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
|
||||
self.ext_user.groups.add(group)
|
||||
self.ext_user.save()
|
||||
|
||||
resp = self.client.get(self.index_url)
|
||||
self.assertContains(resp, self.ext_user.email)
|
||||
|
||||
def test_detail(self):
|
||||
resp = self.client.get(self.detail_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
result = json.loads(resp.content)
|
||||
self.assertEqual(result["role"], None)
|
||||
self.assertTrue(result["active"])
|
||||
|
||||
def test_detail_inactive(self):
|
||||
resp = self.client.get(self.inactive_detail_url)
|
||||
self.assert2XX(resp.status_code)
|
||||
result = json.loads(resp.content)
|
||||
self.assertFalse(result["active"])
|
||||
|
||||
def test_detail_invalid(self):
|
||||
resp = self.client.get(self.invalid_detail_url)
|
||||
self.assert4XX(resp.status_code)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
|
||||
def test_detail_post(self):
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data={"role": None},
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
# no content: should not be in any roles
|
||||
self.assertNotIn(self.staff_groupname, groups)
|
||||
self.assertNotIn(self.inst_groupname, groups)
|
||||
self.assert_not_enrolled()
|
||||
|
||||
def test_detail_post_staff(self):
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"role": "staff"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.staff_groupname, groups)
|
||||
self.assertNotIn(self.inst_groupname, groups)
|
||||
self.assert_enrolled()
|
||||
|
||||
def test_detail_post_staff_other_inst(self):
|
||||
inst_group, _ = Group.objects.get_or_create(name=self.inst_groupname)
|
||||
self.user.groups.add(inst_group)
|
||||
self.user.save()
|
||||
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"role": "staff"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.staff_groupname, groups)
|
||||
self.assertNotIn(self.inst_groupname, groups)
|
||||
self.assert_enrolled()
|
||||
# check that other user is unchanged
|
||||
user = User.objects.get(email=self.user.email)
|
||||
groups = [g.name for g in user.groups.all()]
|
||||
self.assertNotIn(self.staff_groupname, groups)
|
||||
self.assertIn(self.inst_groupname, groups)
|
||||
|
||||
def test_detail_post_instructor(self):
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"role": "instructor"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertNotIn(self.staff_groupname, groups)
|
||||
self.assertIn(self.inst_groupname, groups)
|
||||
self.assert_enrolled()
|
||||
|
||||
def test_detail_post_missing_role(self):
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"toys": "fun"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert4XX(resp.status_code)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
self.assert_not_enrolled()
|
||||
|
||||
def test_detail_post_bad_json(self):
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data="{foo}",
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert4XX(resp.status_code)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
self.assert_not_enrolled()
|
||||
|
||||
def test_detail_post_no_json(self):
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data={"role": "staff"},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.staff_groupname, groups)
|
||||
self.assertNotIn(self.inst_groupname, groups)
|
||||
self.assert_enrolled()
|
||||
|
||||
def test_detail_delete_staff(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
|
||||
self.ext_user.groups.add(group)
|
||||
self.ext_user.save()
|
||||
|
||||
resp = self.client.delete(
|
||||
self.detail_url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertNotIn(self.staff_groupname, groups)
|
||||
|
||||
def test_detail_delete_instructor(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.inst_groupname)
|
||||
self.user.groups.add(group)
|
||||
self.ext_user.groups.add(group)
|
||||
self.user.save()
|
||||
self.ext_user.save()
|
||||
|
||||
resp = self.client.delete(
|
||||
self.detail_url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertNotIn(self.inst_groupname, groups)
|
||||
|
||||
def test_delete_last_instructor(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.inst_groupname)
|
||||
self.ext_user.groups.add(group)
|
||||
self.ext_user.save()
|
||||
|
||||
resp = self.client.delete(
|
||||
self.detail_url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
content = json.loads(resp.content)
|
||||
self.assertEqual(content["Status"], "Failed")
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.inst_groupname, groups)
|
||||
|
||||
def test_post_last_instructor(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.inst_groupname)
|
||||
self.ext_user.groups.add(group)
|
||||
self.ext_user.save()
|
||||
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data={"role": "staff"},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.inst_groupname, groups)
|
||||
|
||||
def test_permission_denied_self(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
|
||||
self.user.groups.add(group)
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
self_url = reverse("course_team_user", kwargs={
|
||||
"org": self.course.location.org,
|
||||
"course": self.course.location.course,
|
||||
"name": self.course.location.name,
|
||||
"email": self.user.email,
|
||||
})
|
||||
|
||||
resp = self.client.post(
|
||||
self_url,
|
||||
data={"role": "instructor"},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert4XX(resp.status_code)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
|
||||
def test_permission_denied_other(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
|
||||
self.user.groups.add(group)
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data={"role": "instructor"},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert4XX(resp.status_code)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
|
||||
def test_staff_can_delete_self(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
|
||||
self.user.groups.add(group)
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
self_url = reverse("course_team_user", kwargs={
|
||||
"org": self.course.location.org,
|
||||
"course": self.course.location.course,
|
||||
"name": self.course.location.name,
|
||||
"email": self.user.email,
|
||||
})
|
||||
|
||||
resp = self.client.delete(self_url)
|
||||
self.assert2XX(resp.status_code)
|
||||
# reload user from DB
|
||||
user = User.objects.get(email=self.user.email)
|
||||
groups = [g.name for g in user.groups.all()]
|
||||
self.assertNotIn(self.staff_groupname, groups)
|
||||
|
||||
def test_staff_cannot_delete_other(self):
|
||||
group, _ = Group.objects.get_or_create(name=self.staff_groupname)
|
||||
self.user.groups.add(group)
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
self.ext_user.groups.add(group)
|
||||
self.ext_user.save()
|
||||
|
||||
resp = self.client.delete(self.detail_url)
|
||||
self.assert4XX(resp.status_code)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
# reload user from DB
|
||||
ext_user = User.objects.get(email=self.ext_user.email)
|
||||
groups = [g.name for g in ext_user.groups.all()]
|
||||
self.assertIn(self.staff_groupname, groups)
|
||||
|
||||
def test_user_not_initially_enrolled(self):
|
||||
# Verify that ext_user is not enrolled in the new course before being added as a staff member.
|
||||
self.assert_not_enrolled()
|
||||
|
||||
def test_remove_staff_does_not_unenroll(self):
|
||||
# Add user with staff permissions.
|
||||
self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"role": "staff"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert_enrolled()
|
||||
# Remove user from staff on course. Will not un-enroll them from the course.
|
||||
resp = self.client.delete(
|
||||
self.detail_url,
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
self.assert_enrolled()
|
||||
|
||||
def test_staff_to_instructor_still_enrolled(self):
|
||||
# Add user with staff permission.
|
||||
self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"role": "staff"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert_enrolled()
|
||||
# Now add with instructor permission. Verify still enrolled.
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data=json.dumps({"role": "instructor"}),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assert2XX(resp.status_code)
|
||||
self.assert_enrolled()
|
||||
|
||||
def assert_not_enrolled(self):
|
||||
""" Asserts that self.ext_user is not enrolled in self.course. """
|
||||
self.assertFalse(
|
||||
is_enrolled_in_course(self.ext_user, self.course.location.course_id),
|
||||
'Did not expect ext_user to be enrolled in course'
|
||||
)
|
||||
|
||||
def assert_enrolled(self):
|
||||
""" Asserts that self.ext_user is enrolled in self.course. """
|
||||
self.assertTrue(
|
||||
is_enrolled_in_course(self.ext_user, self.course.location.course_id),
|
||||
'User ext_user should have been enrolled in the course'
|
||||
)
|
||||
|
||||
@@ -72,50 +72,6 @@ class LMSLinksTestCase(TestCase):
|
||||
)
|
||||
|
||||
|
||||
class UrlReverseTestCase(ModuleStoreTestCase):
|
||||
""" Tests for get_url_reverse """
|
||||
def test_course_page_names(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)
|
||||
)
|
||||
|
||||
|
||||
class ExtraPanelTabTestCase(TestCase):
|
||||
""" Tests adding and removing extra course tabs. """
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.test.client import Client
|
||||
from django.core.cache import cache
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from .utils import parse_json, user, registration
|
||||
@@ -15,14 +16,16 @@ class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
Login. View should always return 200. The success/fail is in the
|
||||
returned json
|
||||
"""
|
||||
resp = self.client.post(reverse('login_post'),
|
||||
{'email': email, 'password': password})
|
||||
resp = self.client.post(
|
||||
reverse('login_post'),
|
||||
{'email': email, 'password': password}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return resp
|
||||
|
||||
def login(self, email, pw):
|
||||
def login(self, email, password):
|
||||
"""Login, check that it worked."""
|
||||
resp = self._login(email, pw)
|
||||
resp = self._login(email, password)
|
||||
data = parse_json(resp)
|
||||
self.assertTrue(data['success'])
|
||||
return resp
|
||||
@@ -77,6 +80,8 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
self.pw = 'xyz'
|
||||
self.username = 'testuser'
|
||||
self.client = Client()
|
||||
# clear the cache so ratelimiting won't affect these tests
|
||||
cache.clear()
|
||||
|
||||
def check_page_get(self, url, expected):
|
||||
resp = self.client.get(url)
|
||||
@@ -117,6 +122,18 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
# Now login should work
|
||||
self.login(self.email, self.pw)
|
||||
|
||||
def test_login_ratelimited(self):
|
||||
# try logging in 30 times, the default limit in the number of failed
|
||||
# login attempts in one 5 minute period before the rate gets limited
|
||||
for i in xrange(30):
|
||||
resp = self._login(self.email, 'wrong_password{0}'.format(i))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp = self._login(self.email, 'wrong_password')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('Too many failed login attempts.', data['value'])
|
||||
|
||||
def test_login_link_on_activation_age(self):
|
||||
self.create_account(self.username, self.email, self.pw)
|
||||
# we want to test the rendering of the activation page when the user isn't logged in
|
||||
@@ -178,11 +195,15 @@ class ForumTestCase(CourseTestCase):
|
||||
|
||||
def test_blackouts(self):
|
||||
now = datetime.datetime.now(UTC)
|
||||
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
|
||||
[(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)),
|
||||
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
|
||||
times1 = [
|
||||
(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)),
|
||||
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))
|
||||
]
|
||||
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in times1]
|
||||
self.assertTrue(self.course.forum_posts_allowed)
|
||||
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
|
||||
[(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)),
|
||||
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
|
||||
times2 = [
|
||||
(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)),
|
||||
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))
|
||||
]
|
||||
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in times2]
|
||||
self.assertFalse(self.course.forum_posts_allowed)
|
||||
|
||||
@@ -188,38 +188,6 @@ def update_item(location, value):
|
||||
get_modulestore(location).update_item(location, value)
|
||||
|
||||
|
||||
def get_url_reverse(course_page_name, course_module):
|
||||
"""
|
||||
Returns the course URL link to the specified location. This value is suitable to use as an href link.
|
||||
|
||||
course_page_name should correspond to an attribute in CoursePageNames (for example, 'ManageUsers'
|
||||
or 'SettingsDetails'), or else it will simply be returned. This method passes back unknown values of
|
||||
course_page_names so that it can also be used for absolute (known) URLs.
|
||||
|
||||
course_module is used to obtain the location, org, course, and name properties for a course, if
|
||||
course_page_name corresponds to an attribute in CoursePageNames.
|
||||
"""
|
||||
url_name = getattr(CoursePageNames, course_page_name, None)
|
||||
ctx_loc = course_module.location
|
||||
|
||||
if CoursePageNames.ManageUsers == url_name:
|
||||
return reverse(url_name, kwargs={"location": ctx_loc})
|
||||
elif url_name in [CoursePageNames.SettingsDetails, CoursePageNames.SettingsGrading,
|
||||
CoursePageNames.CourseOutline, CoursePageNames.Checklists]:
|
||||
return reverse(url_name, kwargs={'org': ctx_loc.org, 'course': ctx_loc.course, 'name': ctx_loc.name})
|
||||
else:
|
||||
return course_page_name
|
||||
|
||||
|
||||
class CoursePageNames:
|
||||
""" Constants for pages that are recognized by get_url_reverse method. """
|
||||
ManageUsers = "manage_users"
|
||||
SettingsDetails = "settings_details"
|
||||
SettingsGrading = "settings_grading"
|
||||
CourseOutline = "course_index"
|
||||
Checklists = "checklists"
|
||||
|
||||
|
||||
def add_extra_panel_tab(tab_type, course):
|
||||
"""
|
||||
Used to add the panel tab to a course if it does not exist.
|
||||
|
||||
@@ -15,3 +15,7 @@ from .public import *
|
||||
from .user import *
|
||||
from .tabs import *
|
||||
from .requests import *
|
||||
try:
|
||||
from .dev import *
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -29,7 +29,6 @@ from xmodule.util.date_utils import get_default_time_display
|
||||
from xmodule.modulestore import InvalidLocationError
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from ..utils import get_url_reverse
|
||||
from .access import get_location_and_verify_access
|
||||
from util.json_request import JsonResponse
|
||||
|
||||
@@ -106,6 +105,7 @@ def asset_index(request, org, course, name):
|
||||
|
||||
asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
display_info['portable_url'] = StaticContent.get_static_path_from_location(asset_location)
|
||||
|
||||
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
|
||||
_thumbnail_location = asset.get('thumbnail_location', None)
|
||||
@@ -188,12 +188,12 @@ def upload_asset(request, org, course, coursename):
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'portable_url': StaticContent.get_static_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
}
|
||||
|
||||
response = JsonResponse(response_payload)
|
||||
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
|
||||
return response
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ def import_course(request, org, course, name):
|
||||
tar_file.extractall(course_dir + '/')
|
||||
|
||||
# find the 'course.xml' file
|
||||
|
||||
dirpath = None
|
||||
for dirpath, _dirnames, filenames in os.walk(course_dir):
|
||||
for filename in filenames:
|
||||
if filename == 'course.xml':
|
||||
@@ -320,7 +320,11 @@ def import_course(request, org, course, name):
|
||||
|
||||
return render_to_response('import.html', {
|
||||
'context_course': course_module,
|
||||
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
|
||||
'successful_import_redirect_url': reverse('course_index', kwargs={
|
||||
'org': location.org,
|
||||
'course': location.course,
|
||||
'name': location.name,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ from util.json_request import JsonResponse
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.urlresolvers import reverse
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from ..utils import get_modulestore, get_url_reverse
|
||||
from ..utils import get_modulestore
|
||||
from .access import get_location_and_verify_access
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
@@ -96,10 +97,25 @@ def expand_checklist_action_urls(course_module):
|
||||
"""
|
||||
checklists = course_module.checklists
|
||||
modified = False
|
||||
urlconf_map = {
|
||||
"ManageUsers": "manage_users",
|
||||
"SettingsDetails": "settings_details",
|
||||
"SettingsGrading": "settings_grading",
|
||||
"CourseOutline": "course_index",
|
||||
"Checklists": "checklists",
|
||||
}
|
||||
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)
|
||||
action_url = item.get('action_url')
|
||||
if action_url not in urlconf_map:
|
||||
continue
|
||||
urlconf_name = urlconf_map[action_url]
|
||||
item['action_url'] = reverse(urlconf_name, kwargs={
|
||||
'org': course_module.location.org,
|
||||
'course': course_module.location.course,
|
||||
'name': course_module.location.name,
|
||||
})
|
||||
checklist['action_urls_expanded'] = True
|
||||
modified = True
|
||||
|
||||
|
||||
@@ -46,13 +46,19 @@ COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video']
|
||||
|
||||
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
|
||||
NOTE_COMPONENT_TYPES = ['notes']
|
||||
ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud', 'videoalpha'] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
|
||||
ADVANCED_COMPONENT_TYPES = [
|
||||
'annotatable',
|
||||
'word_cloud',
|
||||
'videoalpha',
|
||||
'graphical_slider_tool'
|
||||
] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_subsection(request, location):
|
||||
"Edit the subsection of a course"
|
||||
# check that we have permissions to edit this item
|
||||
try:
|
||||
course = get_course_for_item(location)
|
||||
@@ -264,6 +270,7 @@ def assignment_type_update(request, org, course, category, name):
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_draft(request):
|
||||
"Create a draft"
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
@@ -280,6 +287,7 @@ def create_draft(request):
|
||||
@login_required
|
||||
@expect_json
|
||||
def publish_draft(request):
|
||||
"Publish a draft"
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
@@ -295,6 +303,7 @@ def publish_draft(request):
|
||||
@login_required
|
||||
@expect_json
|
||||
def unpublish_unit(request):
|
||||
"Unpublish a unit"
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
@@ -312,6 +321,7 @@ def unpublish_unit(request):
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def module_info(request, module_location):
|
||||
"Get or set information for a module in the modulestore"
|
||||
location = Location(module_location)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
|
||||
@@ -3,6 +3,7 @@ Views related to operations on course objects
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from django.utils.translation import ugettext as _
|
||||
import string # pylint: disable=W0402
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -43,6 +44,8 @@ from .component import (
|
||||
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
|
||||
from student.views import enroll_in_course
|
||||
|
||||
from xmodule.html_module import AboutDescriptor
|
||||
__all__ = ['course_index', 'create_new_course', 'course_info',
|
||||
'course_info_updates', 'get_course_settings',
|
||||
@@ -101,12 +104,13 @@ def create_new_course(request):
|
||||
org = request.POST.get('org')
|
||||
number = request.POST.get('number')
|
||||
display_name = request.POST.get('display_name')
|
||||
run = request.POST.get('run')
|
||||
|
||||
try:
|
||||
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
|
||||
dest_location = Location('i4x', org, number, 'course', run)
|
||||
except InvalidLocationError as error:
|
||||
return JsonResponse({
|
||||
"ErrMsg": "Unable to create course '{name}'.\n\n{err}".format(
|
||||
"ErrMsg": _("Unable to create course '{name}'.\n\n{err}").format(
|
||||
name=display_name, err=error.message)})
|
||||
|
||||
# see if the course already exists
|
||||
@@ -116,12 +120,24 @@ def create_new_course(request):
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
if existing_course is not None:
|
||||
return JsonResponse({'ErrMsg': 'There is already a course defined with this name.'})
|
||||
return JsonResponse(
|
||||
{
|
||||
'ErrMsg': _('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'),
|
||||
}
|
||||
)
|
||||
|
||||
course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
|
||||
courses = modulestore().get_items(course_search_location)
|
||||
if len(courses) > 0:
|
||||
return JsonResponse({'ErrMsg': 'There is already a course defined with the same organization and course number.'})
|
||||
return JsonResponse(
|
||||
{
|
||||
'ErrMsg': _('There is already a course defined with the same organization and course number. Please change at least one field to be unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'),
|
||||
}
|
||||
)
|
||||
|
||||
# instantiate the CourseDescriptor and then persist it
|
||||
# note: no system to pass
|
||||
@@ -148,6 +164,9 @@ def create_new_course(request):
|
||||
# seed the forums
|
||||
seed_permissions_roles(new_course.location.course_id)
|
||||
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
enroll_in_course(request.user, new_course.location.course_id)
|
||||
|
||||
return JsonResponse({'id': new_course.location.url()})
|
||||
|
||||
|
||||
|
||||
12
cms/djangoapps/contentstore/views/dev.py
Normal file
12
cms/djangoapps/contentstore/views/dev.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Views that are only activated when the project is running in development mode.
|
||||
These views will NOT be shown on production: trying to access them will result
|
||||
in a 404 error.
|
||||
"""
|
||||
# pylint: disable=W0613
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
|
||||
def dev_mode(request):
|
||||
"Sample static view"
|
||||
return render_to_response("dev/dev_mode.html")
|
||||
@@ -68,6 +68,7 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
|
||||
|
||||
@login_required
|
||||
def preview_component(request, location):
|
||||
"Return the HTML preview of a component"
|
||||
# TODO (vshnayder): change name from id to location in coffee+html as well.
|
||||
if not has_access(request.user, location):
|
||||
return HttpResponseForbidden()
|
||||
@@ -91,6 +92,7 @@ def preview_module_system(request, preview_id, descriptor):
|
||||
"""
|
||||
|
||||
def preview_model_data(descriptor):
|
||||
"Helper method to create a DbModel from a descriptor"
|
||||
return DbModel(
|
||||
SessionKeyValueStore(request, descriptor._model_data),
|
||||
descriptor.module_class,
|
||||
@@ -105,7 +107,7 @@ def preview_module_system(request, preview_id, descriptor):
|
||||
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
|
||||
track_function=lambda event_type, event: None,
|
||||
filestore=descriptor.system.resources_fs,
|
||||
get_module=partial(get_preview_module, request, preview_id),
|
||||
get_module=partial(load_preview_module, request, preview_id),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
|
||||
@@ -115,28 +117,13 @@ def preview_module_system(request, preview_id, descriptor):
|
||||
)
|
||||
|
||||
|
||||
def get_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Returns a preview XModule at the specified location. The preview_data is chosen arbitrarily
|
||||
from the set of preview data for the descriptor specified by Location
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
location: A Location
|
||||
"""
|
||||
|
||||
return load_preview_module(request, preview_id, descriptor)
|
||||
|
||||
|
||||
def load_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state
|
||||
Return a preview XModule instantiated from the supplied descriptor.
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
descriptor: An XModuleDescriptor
|
||||
instance_state: An instance state string
|
||||
shared_state: A shared state string
|
||||
"""
|
||||
system = preview_module_system(request, preview_id, descriptor)
|
||||
try:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
Public views
|
||||
"""
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.context_processors import csrf
|
||||
from django.shortcuts import redirect
|
||||
@@ -10,10 +13,6 @@ from .user import index
|
||||
|
||||
__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks']
|
||||
|
||||
"""
|
||||
Public views
|
||||
"""
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def signup(request):
|
||||
@@ -45,6 +44,7 @@ def login_page(request):
|
||||
|
||||
|
||||
def howitworks(request):
|
||||
"Proxy view"
|
||||
if request.user.is_authenticated():
|
||||
return index(request)
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from mitxmako.shortcuts import render_to_string, render_to_response
|
||||
|
||||
__all__ = ['edge', 'event', 'landing']
|
||||
@@ -11,7 +12,7 @@ def landing(request, org, course, coursename):
|
||||
|
||||
# points to the temporary edge page
|
||||
def edge(request):
|
||||
return render_to_response('university_profiles/edge.html', {})
|
||||
return redirect('/')
|
||||
|
||||
|
||||
def event(request):
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
Views related to course tabs
|
||||
"""
|
||||
from access import has_access
|
||||
from util.json_request import expect_json
|
||||
|
||||
@@ -39,6 +42,7 @@ def initialize_course_tabs(course):
|
||||
@login_required
|
||||
@expect_json
|
||||
def reorder_static_tabs(request):
|
||||
"Order the static tabs in the requested order"
|
||||
tabs = request.POST['tabs']
|
||||
course = get_course_for_item(tabs[0])
|
||||
|
||||
@@ -86,6 +90,7 @@ def reorder_static_tabs(request):
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def edit_tabs(request, org, course, coursename):
|
||||
"Edit tabs"
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
store = get_modulestore(location)
|
||||
course_item = store.get_item(location)
|
||||
@@ -122,6 +127,7 @@ def edit_tabs(request, org, course, coursename):
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def static_pages(request, org, course, coursename):
|
||||
"Static pages view"
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, coursename)
|
||||
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import json
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.http import require_POST
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from django.core.context_processors import csrf
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from contentstore.utils import get_url_reverse, get_lms_link_for_item
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, 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 xmodule.modulestore import Location
|
||||
from contentstore.utils import get_lms_link_for_item
|
||||
from util.json_request import JsonResponse
|
||||
from auth.authz import (
|
||||
STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_course_groupname_for_role)
|
||||
from course_creators.views import (
|
||||
get_course_creator_status, add_user_with_status_unrequested,
|
||||
user_requested_access)
|
||||
|
||||
from .access import has_access
|
||||
|
||||
from student.views import enroll_in_course
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -34,113 +44,209 @@ def index(request):
|
||||
and course.location.name != '')
|
||||
courses = filter(course_filter, courses)
|
||||
|
||||
def format_course_for_view(course):
|
||||
return (
|
||||
course.display_name,
|
||||
reverse("course_index", kwargs={
|
||||
'org': course.location.org,
|
||||
'course': course.location.course,
|
||||
'name': course.location.name,
|
||||
}),
|
||||
get_lms_link_for_item(
|
||||
course.location,
|
||||
course_id=course.location.course_id,
|
||||
),
|
||||
course.display_org_with_default,
|
||||
course.display_number_with_default,
|
||||
course.location.name
|
||||
)
|
||||
|
||||
return render_to_response('index.html', {
|
||||
'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],
|
||||
'courses': [format_course_for_view(c) for c in courses],
|
||||
'user': request.user,
|
||||
'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff
|
||||
'request_course_creator_url': reverse('request_course_creator'),
|
||||
'course_creator_status': _get_course_creator_status(request.user),
|
||||
'csrf': csrf(request)['csrf_token']
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def request_course_creator(request):
|
||||
"""
|
||||
User has requested course creation access.
|
||||
"""
|
||||
user_requested_access(request.user)
|
||||
return JsonResponse({"Status": "OK"})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def manage_users(request, location):
|
||||
def manage_users(request, org, course, name):
|
||||
'''
|
||||
This view will return all CMS users who are editors for the specified course
|
||||
'''
|
||||
location = Location('i4x', org, course, 'course', name)
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
staff_groupname = get_course_groupname_for_role(location, "staff")
|
||||
staff_group, __ = Group.objects.get_or_create(name=staff_groupname)
|
||||
inst_groupname = get_course_groupname_for_role(location, "instructor")
|
||||
inst_group, __ = Group.objects.get_or_create(name=inst_groupname)
|
||||
|
||||
return render_to_response('manage_users.html', {
|
||||
'context_course': course_module,
|
||||
'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME),
|
||||
'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'),
|
||||
'remove_user_postback_url': reverse('remove_user', args=[location]).rstrip('/'),
|
||||
'staff': staff_group.user_set.all(),
|
||||
'instructors': inst_group.user_set.all(),
|
||||
'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME),
|
||||
'request_user_id': request.user.id
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def add_user(request, location):
|
||||
'''
|
||||
This POST-back view will add a user - specified by email - to the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
email = request.POST.get("email")
|
||||
|
||||
if not email:
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
def course_team_user(request, org, course, name, email):
|
||||
location = Location('i4x', org, course, 'course', name)
|
||||
# check that logged in user has permissions to this item
|
||||
if has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
# instructors have full permissions
|
||||
pass
|
||||
elif has_access(request.user, location, role=STAFF_ROLE_NAME) and email == request.user.email:
|
||||
# staff can only affect themselves
|
||||
pass
|
||||
else:
|
||||
msg = {
|
||||
'Status': 'Failed',
|
||||
'ErrMsg': _('Please specify an email address.'),
|
||||
"error": _("Insufficient permissions")
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
|
||||
# remove leading/trailing whitespace if necessary
|
||||
email = email.strip()
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
|
||||
# user doesn't exist?!? Return error.
|
||||
if user is None:
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
except:
|
||||
msg = {
|
||||
'Status': 'Failed',
|
||||
'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email),
|
||||
"error": _("Could not find user by email address '{email}'.").format(email=email),
|
||||
}
|
||||
return JsonResponse(msg, 404)
|
||||
|
||||
# user exists, but hasn't activated account?!?
|
||||
# role hierarchy: "instructor" has more permissions than "staff" (in a course)
|
||||
roles = ["instructor", "staff"]
|
||||
|
||||
if request.method == "GET":
|
||||
# just return info about the user
|
||||
msg = {
|
||||
"email": user.email,
|
||||
"active": user.is_active,
|
||||
"role": None,
|
||||
}
|
||||
# what's the highest role that this user has?
|
||||
groupnames = set(g.name for g in user.groups.all())
|
||||
for role in roles:
|
||||
role_groupname = get_course_groupname_for_role(location, role)
|
||||
if role_groupname in groupnames:
|
||||
msg["role"] = role
|
||||
break
|
||||
return JsonResponse(msg)
|
||||
|
||||
# can't modify an inactive user
|
||||
if not user.is_active:
|
||||
msg = {
|
||||
'Status': 'Failed',
|
||||
'ErrMsg': _('User {email} has registered but has not yet activated his/her account.').format(email=email),
|
||||
"error": _('User {email} has registered but has not yet activated his/her account.').format(email=email),
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
|
||||
# ok, we're cool to add to the course group
|
||||
add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
# make sure that the role groups exist
|
||||
groups = {}
|
||||
for role in roles:
|
||||
groupname = get_course_groupname_for_role(location, role)
|
||||
group, __ = Group.objects.get_or_create(name=groupname)
|
||||
groups[role] = group
|
||||
|
||||
return JsonResponse({"Status": "OK"})
|
||||
if request.method == "DELETE":
|
||||
# remove all roles in this course from this user: but fail if the user
|
||||
# is the last instructor in the course team
|
||||
instructors = set(groups["instructor"].user_set.all())
|
||||
staff = set(groups["staff"].user_set.all())
|
||||
if user in instructors and len(instructors) == 1:
|
||||
msg = {
|
||||
"error": _("You may not remove the last instructor from a course")
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
|
||||
if user in instructors:
|
||||
user.groups.remove(groups["instructor"])
|
||||
if user in staff:
|
||||
user.groups.remove(groups["staff"])
|
||||
user.save()
|
||||
return JsonResponse()
|
||||
|
||||
# all other operations require the requesting user to specify a role
|
||||
if request.META.get("CONTENT_TYPE", "").startswith("application/json") and request.body:
|
||||
try:
|
||||
payload = json.loads(request.body)
|
||||
except:
|
||||
return JsonResponse({"error": _("malformed JSON")}, 400)
|
||||
try:
|
||||
role = payload["role"]
|
||||
except KeyError:
|
||||
return JsonResponse({"error": _("`role` is required")}, 400)
|
||||
else:
|
||||
if not "role" in request.POST:
|
||||
return JsonResponse({"error": _("`role` is required")}, 400)
|
||||
role = request.POST["role"]
|
||||
|
||||
if role == "instructor":
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
msg = {
|
||||
"error": _("Only instructors may create other instructors")
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
user.groups.add(groups["instructor"])
|
||||
user.save()
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
enroll_in_course(user, location.course_id)
|
||||
elif role == "staff":
|
||||
# if we're trying to downgrade a user from "instructor" to "staff",
|
||||
# make sure we have at least one other instructor in the course team.
|
||||
instructors = set(groups["instructor"].user_set.all())
|
||||
if user in instructors:
|
||||
if len(instructors) == 1:
|
||||
msg = {
|
||||
"error": _("You may not remove the last instructor from a course")
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
user.groups.remove(groups["instructor"])
|
||||
user.groups.add(groups["staff"])
|
||||
user.save()
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
enroll_in_course(user, location.course_id)
|
||||
|
||||
return JsonResponse()
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def remove_user(request, location):
|
||||
'''
|
||||
This POST-back view will remove a user - specified by email - from the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
def _get_course_creator_status(user):
|
||||
"""
|
||||
Helper method for returning the course creator status for a particular user,
|
||||
taking into account the values of DISABLE_COURSE_CREATION and ENABLE_CREATOR_GROUP.
|
||||
|
||||
email = request.POST["email"]
|
||||
If the user passed in has not previously visited the index page, it will be
|
||||
added with status 'unrequested' if the course creator group is in use.
|
||||
"""
|
||||
if user.is_staff:
|
||||
course_creator_status = 'granted'
|
||||
elif settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False):
|
||||
course_creator_status = 'disallowed_for_this_site'
|
||||
elif settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False):
|
||||
course_creator_status = get_course_creator_status(user)
|
||||
if course_creator_status is None:
|
||||
# User not grandfathered in as an existing user, has not previously visited the dashboard page.
|
||||
# Add the user to the course creator admin table with status 'unrequested'.
|
||||
add_user_with_status_unrequested(user)
|
||||
course_creator_status = get_course_creator_status(user)
|
||||
else:
|
||||
course_creator_status = 'granted'
|
||||
|
||||
# check that logged in user has admin permissions on this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user is None:
|
||||
msg = {
|
||||
'Status': 'Failed',
|
||||
'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email),
|
||||
}
|
||||
return JsonResponse(msg, 404)
|
||||
|
||||
# make sure we're not removing ourselves
|
||||
if user.id == request.user.id:
|
||||
raise PermissionDenied()
|
||||
|
||||
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return JsonResponse({"Status": "OK"})
|
||||
return course_creator_status
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
django admin page for the course creators table
|
||||
"""
|
||||
|
||||
from course_creators.models import CourseCreator, update_creator_state
|
||||
from course_creators.models import CourseCreator, update_creator_state, send_user_notification, send_admin_notification
|
||||
from course_creators.views import update_course_creator_group
|
||||
|
||||
from django.contrib import admin
|
||||
from ratelimitbackend import admin
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from django.core.mail import send_mail
|
||||
from smtplib import SMTPException
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("studio.coursecreatoradmin")
|
||||
|
||||
|
||||
def get_email(obj):
|
||||
@@ -22,12 +30,12 @@ class CourseCreatorAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
|
||||
# Fields to display on the overview page.
|
||||
list_display = ['user', get_email, 'state', 'state_changed', 'note']
|
||||
readonly_fields = ['user', 'state_changed']
|
||||
list_display = ['username', get_email, 'state', 'state_changed', 'note']
|
||||
readonly_fields = ['username', 'state_changed']
|
||||
# Controls the order on the edit form (without this, read-only fields appear at the end).
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ['user', 'state', 'state_changed', 'note']
|
||||
'fields': ['username', 'state', 'state_changed', 'note']
|
||||
}),
|
||||
)
|
||||
# Fields that filtering support
|
||||
@@ -37,6 +45,16 @@ class CourseCreatorAdmin(admin.ModelAdmin):
|
||||
# Turn off the action bar (we have no bulk actions)
|
||||
actions = None
|
||||
|
||||
def username(self, inst):
|
||||
"""
|
||||
Returns the username for a given user.
|
||||
|
||||
Implemented to make sorting by username instead of by user object.
|
||||
"""
|
||||
return inst.user.username
|
||||
|
||||
username.admin_order_field = 'user__username'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
@@ -60,4 +78,60 @@ def update_creator_group_callback(sender, **kwargs):
|
||||
"""
|
||||
Callback for when the model's creator status has changed.
|
||||
"""
|
||||
update_course_creator_group(kwargs['caller'], kwargs['user'], kwargs['add'])
|
||||
user = kwargs['user']
|
||||
updated_state = kwargs['state']
|
||||
update_course_creator_group(kwargs['caller'], user, updated_state == CourseCreator.GRANTED)
|
||||
|
||||
|
||||
@receiver(send_user_notification, sender=CourseCreator)
|
||||
def send_user_notification_callback(sender, **kwargs):
|
||||
"""
|
||||
Callback for notifying user about course creator status change.
|
||||
"""
|
||||
user = kwargs['user']
|
||||
updated_state = kwargs['state']
|
||||
|
||||
studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL', '')
|
||||
context = {'studio_request_email': studio_request_email}
|
||||
|
||||
subject = render_to_string('emails/course_creator_subject.txt', context)
|
||||
subject = ''.join(subject.splitlines())
|
||||
if updated_state == CourseCreator.GRANTED:
|
||||
message_template = 'emails/course_creator_granted.txt'
|
||||
elif updated_state == CourseCreator.DENIED:
|
||||
message_template = 'emails/course_creator_denied.txt'
|
||||
else:
|
||||
# changed to unrequested or pending
|
||||
message_template = 'emails/course_creator_revoked.txt'
|
||||
message = render_to_string(message_template, context)
|
||||
|
||||
try:
|
||||
user.email_user(subject, message, studio_request_email)
|
||||
except:
|
||||
log.warning("Unable to send course creator status e-mail to %s", user.email)
|
||||
|
||||
|
||||
@receiver(send_admin_notification, sender=CourseCreator)
|
||||
def send_admin_notification_callback(sender, **kwargs):
|
||||
"""
|
||||
Callback for notifying admin of a user in the 'pending' state.
|
||||
"""
|
||||
user = kwargs['user']
|
||||
|
||||
studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL', '')
|
||||
context = {'user_name': user.username, 'user_email': user.email}
|
||||
|
||||
subject = render_to_string('emails/course_creator_admin_subject.txt', context)
|
||||
subject = ''.join(subject.splitlines())
|
||||
message = render_to_string('emails/course_creator_admin_user_pending.txt', context)
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject,
|
||||
message,
|
||||
studio_request_email,
|
||||
[studio_request_email],
|
||||
fail_silently=False
|
||||
)
|
||||
except SMTPException:
|
||||
log.warning("Failure sending 'pending state' e-mail for %s to %s", user.email, studio_request_email)
|
||||
|
||||
@@ -10,7 +10,13 @@ from django.utils import timezone
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
# A signal that will be sent when users should be added or removed from the creator group
|
||||
update_creator_state = Signal(providing_args=["caller", "user", "add"])
|
||||
update_creator_state = Signal(providing_args=["caller", "user", "state"])
|
||||
|
||||
# A signal that will be sent when admin should be notified of a pending user request
|
||||
send_admin_notification = Signal(providing_args=["user"])
|
||||
|
||||
# A signal that will be sent when user should be notified of change in course creator privileges
|
||||
send_user_notification = Signal(providing_args=["user", "state"])
|
||||
|
||||
|
||||
class CourseCreator(models.Model):
|
||||
@@ -39,7 +45,7 @@ class CourseCreator(models.Model):
|
||||
"why course creation access was denied)"))
|
||||
|
||||
def __unicode__(self):
|
||||
return u'%str | %str [%str] | %str' % (self.user, self.state, self.state_changed, self.note)
|
||||
return u"{0} | {1} [{2}]".format(self.user, self.state, self.state_changed)
|
||||
|
||||
|
||||
@receiver(post_init, sender=CourseCreator)
|
||||
@@ -54,18 +60,40 @@ def post_init_callback(sender, **kwargs):
|
||||
@receiver(post_save, sender=CourseCreator)
|
||||
def post_save_callback(sender, **kwargs):
|
||||
"""
|
||||
Extend to update state_changed time and modify the course creator group in authz.py.
|
||||
Extend to update state_changed time and fire event to update course creator group, if appropriate.
|
||||
"""
|
||||
instance = kwargs['instance']
|
||||
# We only wish to modify the state_changed time if the state has been modified. We don't wish to
|
||||
# modify it for changes to the notes field.
|
||||
if instance.state != instance.orig_state:
|
||||
update_creator_state.send(
|
||||
sender=sender,
|
||||
caller=instance.admin,
|
||||
user=instance.user,
|
||||
add=instance.state == CourseCreator.GRANTED
|
||||
)
|
||||
granted_state_change = instance.state == CourseCreator.GRANTED or instance.orig_state == CourseCreator.GRANTED
|
||||
# If either old or new state is 'granted', we must manipulate the course creator
|
||||
# group maintained by authz. That requires staff permissions (stored admin).
|
||||
if granted_state_change:
|
||||
assert hasattr(instance, 'admin'), 'Must have stored staff user to change course creator group'
|
||||
update_creator_state.send(
|
||||
sender=sender,
|
||||
caller=instance.admin,
|
||||
user=instance.user,
|
||||
state=instance.state
|
||||
)
|
||||
|
||||
# If user has been denied access, granted access, or previously granted access has been
|
||||
# revoked, send a notification message to the user.
|
||||
if instance.state == CourseCreator.DENIED or granted_state_change:
|
||||
send_user_notification.send(
|
||||
sender=sender,
|
||||
user=instance.user,
|
||||
state=instance.state
|
||||
)
|
||||
|
||||
# If the user has gone into the 'pending' state, send a notification to interested admin.
|
||||
if instance.state == CourseCreator.PENDING:
|
||||
send_admin_notification.send(
|
||||
sender=sender,
|
||||
user=instance.user
|
||||
)
|
||||
|
||||
instance.state_changed = timezone.now()
|
||||
instance.orig_state = instance.state
|
||||
instance.save()
|
||||
|
||||
@@ -11,6 +11,12 @@ import mock
|
||||
from course_creators.admin import CourseCreatorAdmin
|
||||
from course_creators.models import CourseCreator
|
||||
from auth.authz import is_user_in_creator_group
|
||||
from django.core import mail
|
||||
|
||||
|
||||
def mock_render_to_string(template_name, context):
|
||||
"""Return a string that encodes template_name and context"""
|
||||
return str((template_name, context))
|
||||
|
||||
|
||||
class CourseCreatorAdminTest(TestCase):
|
||||
@@ -32,31 +38,104 @@ class CourseCreatorAdminTest(TestCase):
|
||||
|
||||
self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite())
|
||||
|
||||
def test_change_status(self):
|
||||
self.studio_request_email = 'mark@marky.mark'
|
||||
self.enable_creator_group_patch = {
|
||||
"ENABLE_CREATOR_GROUP": True,
|
||||
"STUDIO_REQUEST_EMAIL": self.studio_request_email
|
||||
}
|
||||
|
||||
@mock.patch('course_creators.admin.render_to_string', mock.Mock(side_effect=mock_render_to_string, autospec=True))
|
||||
@mock.patch('django.contrib.auth.models.User.email_user')
|
||||
def test_change_status(self, email_user):
|
||||
"""
|
||||
Tests that updates to state impact the creator group maintained in authz.py.
|
||||
Tests that updates to state impact the creator group maintained in authz.py and that e-mails are sent.
|
||||
"""
|
||||
def change_state(state, is_creator):
|
||||
""" Helper method for changing state """
|
||||
self.table_entry.state = state
|
||||
self.creator_admin.save_model(self.request, self.table_entry, None, True)
|
||||
|
||||
def change_state_and_verify_email(state, is_creator):
|
||||
""" Changes user state, verifies creator status, and verifies e-mail is sent based on transition """
|
||||
self._change_state(state)
|
||||
self.assertEqual(is_creator, is_user_in_creator_group(self.user))
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
context = {'studio_request_email': self.studio_request_email}
|
||||
if state == CourseCreator.GRANTED:
|
||||
template = 'emails/course_creator_granted.txt'
|
||||
elif state == CourseCreator.DENIED:
|
||||
template = 'emails/course_creator_denied.txt'
|
||||
else:
|
||||
template = 'emails/course_creator_revoked.txt'
|
||||
email_user.assert_called_with(
|
||||
mock_render_to_string('emails/course_creator_subject.txt', context),
|
||||
mock_render_to_string(template, context),
|
||||
self.studio_request_email
|
||||
)
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group_patch):
|
||||
|
||||
# User is initially unrequested.
|
||||
self.assertFalse(is_user_in_creator_group(self.user))
|
||||
|
||||
change_state(CourseCreator.GRANTED, True)
|
||||
change_state_and_verify_email(CourseCreator.GRANTED, True)
|
||||
|
||||
change_state(CourseCreator.DENIED, False)
|
||||
change_state_and_verify_email(CourseCreator.DENIED, False)
|
||||
|
||||
change_state(CourseCreator.GRANTED, True)
|
||||
change_state_and_verify_email(CourseCreator.GRANTED, True)
|
||||
|
||||
change_state(CourseCreator.PENDING, False)
|
||||
change_state_and_verify_email(CourseCreator.PENDING, False)
|
||||
|
||||
change_state(CourseCreator.GRANTED, True)
|
||||
change_state_and_verify_email(CourseCreator.GRANTED, True)
|
||||
|
||||
change_state(CourseCreator.UNREQUESTED, False)
|
||||
change_state_and_verify_email(CourseCreator.UNREQUESTED, False)
|
||||
|
||||
change_state_and_verify_email(CourseCreator.DENIED, False)
|
||||
|
||||
@mock.patch('course_creators.admin.render_to_string', mock.Mock(side_effect=mock_render_to_string, autospec=True))
|
||||
def test_mail_admin_on_pending(self):
|
||||
"""
|
||||
Tests that the admin account is notified when a user is in the 'pending' state.
|
||||
"""
|
||||
|
||||
def check_admin_message_state(state, expect_sent_to_admin, expect_sent_to_user):
|
||||
""" Changes user state and verifies e-mail sent to admin address only when pending. """
|
||||
mail.outbox = []
|
||||
self._change_state(state)
|
||||
|
||||
# If a message is sent to the user about course creator status change, it will be the first
|
||||
# message sent. Admin message will follow.
|
||||
base_num_emails = 1 if expect_sent_to_user else 0
|
||||
if expect_sent_to_admin:
|
||||
context = {'user_name': "test_user", 'user_email': 'test_user+courses@edx.org'}
|
||||
self.assertEquals(base_num_emails + 1, len(mail.outbox), 'Expected admin message to be sent')
|
||||
sent_mail = mail.outbox[base_num_emails]
|
||||
self.assertEquals(
|
||||
mock_render_to_string('emails/course_creator_admin_subject.txt', context),
|
||||
sent_mail.subject
|
||||
)
|
||||
self.assertEquals(
|
||||
mock_render_to_string('emails/course_creator_admin_user_pending.txt', context),
|
||||
sent_mail.body
|
||||
)
|
||||
self.assertEquals(self.studio_request_email, sent_mail.from_email)
|
||||
self.assertEqual([self.studio_request_email], sent_mail.to)
|
||||
else:
|
||||
self.assertEquals(base_num_emails, len(mail.outbox))
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group_patch):
|
||||
# E-mail message should be sent to admin only when new state is PENDING, regardless of what
|
||||
# previous state was (unless previous state was already PENDING).
|
||||
# E-mail message sent to user only on transition into and out of GRANTED state.
|
||||
check_admin_message_state(CourseCreator.UNREQUESTED, expect_sent_to_admin=False, expect_sent_to_user=False)
|
||||
check_admin_message_state(CourseCreator.PENDING, expect_sent_to_admin=True, expect_sent_to_user=False)
|
||||
check_admin_message_state(CourseCreator.GRANTED, expect_sent_to_admin=False, expect_sent_to_user=True)
|
||||
check_admin_message_state(CourseCreator.DENIED, expect_sent_to_admin=False, expect_sent_to_user=True)
|
||||
check_admin_message_state(CourseCreator.GRANTED, expect_sent_to_admin=False, expect_sent_to_user=True)
|
||||
check_admin_message_state(CourseCreator.PENDING, expect_sent_to_admin=True, expect_sent_to_user=True)
|
||||
check_admin_message_state(CourseCreator.PENDING, expect_sent_to_admin=False, expect_sent_to_user=False)
|
||||
check_admin_message_state(CourseCreator.DENIED, expect_sent_to_admin=False, expect_sent_to_user=True)
|
||||
|
||||
def _change_state(self, state):
|
||||
""" Helper method for changing state """
|
||||
self.table_entry.state = state
|
||||
self.creator_admin.save_model(self.request, self.table_entry, None, True)
|
||||
|
||||
def test_add_permission(self):
|
||||
"""
|
||||
@@ -78,3 +157,18 @@ class CourseCreatorAdminTest(TestCase):
|
||||
|
||||
self.request.user = self.user
|
||||
self.assertFalse(self.creator_admin.has_change_permission(self.request))
|
||||
|
||||
def test_rate_limit_login(self):
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_CREATOR_GROUP': True}):
|
||||
post_params = {'username': self.user.username, 'password': 'wrong_password'}
|
||||
# try logging in 30 times, the default limit in the number of failed
|
||||
# login attempts in one 5 minute period before the rate gets limited
|
||||
for _ in xrange(30):
|
||||
response = self.client.post('/admin/', post_params)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
response = self.client.post('/admin/', post_params)
|
||||
# Since we are using the default rate limit behavior, we are
|
||||
# expecting this to return a 403 error to indicate that there have
|
||||
# been too many attempts
|
||||
self.assertEquals(response.status_code, 403)
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.contrib.auth.models import User
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from course_creators.views import add_user_with_status_unrequested, add_user_with_status_granted
|
||||
from course_creators.views import get_course_creator_status, update_course_creator_group
|
||||
from course_creators.views import get_course_creator_status, update_course_creator_group, user_requested_access
|
||||
from course_creators.models import CourseCreator
|
||||
from auth.authz import is_user_in_creator_group
|
||||
import mock
|
||||
@@ -26,14 +26,11 @@ class CourseCreatorView(TestCase):
|
||||
|
||||
def test_staff_permission_required(self):
|
||||
"""
|
||||
Tests that add methods and course creator group method must be called with staff permissions.
|
||||
Tests that any method changing the course creator authz group must be called with staff permissions.
|
||||
"""
|
||||
with self.assertRaises(PermissionDenied):
|
||||
add_user_with_status_granted(self.user, self.user)
|
||||
|
||||
with self.assertRaises(PermissionDenied):
|
||||
add_user_with_status_unrequested(self.user, self.user)
|
||||
|
||||
with self.assertRaises(PermissionDenied):
|
||||
update_course_creator_group(self.user, self.user, True)
|
||||
|
||||
@@ -41,7 +38,7 @@ class CourseCreatorView(TestCase):
|
||||
self.assertIsNone(get_course_creator_status(self.user))
|
||||
|
||||
def test_add_unrequested(self):
|
||||
add_user_with_status_unrequested(self.admin, self.user)
|
||||
add_user_with_status_unrequested(self.user)
|
||||
self.assertEqual('unrequested', get_course_creator_status(self.user))
|
||||
|
||||
# Calling add again will be a no-op (even if state is different).
|
||||
@@ -57,7 +54,7 @@ class CourseCreatorView(TestCase):
|
||||
self.assertEqual('granted', get_course_creator_status(self.user))
|
||||
|
||||
# Calling add again will be a no-op (even if state is different).
|
||||
add_user_with_status_unrequested(self.admin, self.user)
|
||||
add_user_with_status_unrequested(self.user)
|
||||
self.assertEqual('granted', get_course_creator_status(self.user))
|
||||
|
||||
self.assertTrue(is_user_in_creator_group(self.user))
|
||||
@@ -69,3 +66,27 @@ class CourseCreatorView(TestCase):
|
||||
self.assertTrue(is_user_in_creator_group(self.user))
|
||||
update_course_creator_group(self.admin, self.user, False)
|
||||
self.assertFalse(is_user_in_creator_group(self.user))
|
||||
|
||||
def test_user_requested_access(self):
|
||||
add_user_with_status_unrequested(self.user)
|
||||
self.assertEqual('unrequested', get_course_creator_status(self.user))
|
||||
user_requested_access(self.user)
|
||||
self.assertEqual('pending', get_course_creator_status(self.user))
|
||||
|
||||
def test_user_requested_already_granted(self):
|
||||
add_user_with_status_granted(self.admin, self.user)
|
||||
self.assertEqual('granted', get_course_creator_status(self.user))
|
||||
# Will not "downgrade" to pending because that would require removing the
|
||||
# user from the authz course creator group (and that can only be done by an admin).
|
||||
user_requested_access(self.user)
|
||||
self.assertEqual('granted', get_course_creator_status(self.user))
|
||||
|
||||
def test_add_user_unrequested_staff(self):
|
||||
# Users marked as is_staff will not be added to the course creator table.
|
||||
add_user_with_status_unrequested(self.admin)
|
||||
self.assertIsNone(get_course_creator_status(self.admin))
|
||||
|
||||
def test_add_user_granted_staff(self):
|
||||
# Users marked as is_staff will not be added to the course creator table.
|
||||
add_user_with_status_granted(self.admin, self.admin)
|
||||
self.assertIsNone(get_course_creator_status(self.admin))
|
||||
|
||||
@@ -2,32 +2,38 @@
|
||||
Methods for interacting programmatically with the user creator table.
|
||||
"""
|
||||
from course_creators.models import CourseCreator
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from auth.authz import add_user_to_creator_group, remove_user_from_creator_group
|
||||
|
||||
|
||||
def add_user_with_status_unrequested(caller, user):
|
||||
def add_user_with_status_unrequested(user):
|
||||
"""
|
||||
Adds a user to the course creator table with status 'unrequested'.
|
||||
|
||||
If the user is already in the table, this method is a no-op
|
||||
(state will not be changed). Caller must have staff permissions.
|
||||
(state will not be changed).
|
||||
|
||||
If the user is marked as is_staff, this method is a no-op (user
|
||||
will not be added to table).
|
||||
"""
|
||||
_add_user(caller, user, CourseCreator.UNREQUESTED)
|
||||
_add_user(user, CourseCreator.UNREQUESTED)
|
||||
|
||||
|
||||
def add_user_with_status_granted(caller, user):
|
||||
"""
|
||||
Adds a user to the course creator table with status 'granted'.
|
||||
|
||||
If the user is already in the table, this method is a no-op
|
||||
(state will not be changed). Caller must have staff permissions.
|
||||
If appropriate, this method also adds the user to the course creator group maintained by authz.py.
|
||||
Caller must have staff permissions.
|
||||
|
||||
This method also adds the user to the course creator group maintained by authz.py.
|
||||
If the user is already in the table, this method is a no-op
|
||||
(state will not be changed).
|
||||
|
||||
If the user is marked as is_staff, this method is a no-op (user
|
||||
will not be added to table, nor added to authz.py group).
|
||||
"""
|
||||
_add_user(caller, user, CourseCreator.GRANTED)
|
||||
update_course_creator_group(caller, user, True)
|
||||
if _add_user(user, CourseCreator.GRANTED):
|
||||
update_course_creator_group(caller, user, True)
|
||||
|
||||
|
||||
def update_course_creator_group(caller, user, add):
|
||||
@@ -61,16 +67,33 @@ def get_course_creator_status(user):
|
||||
return user[0].state
|
||||
|
||||
|
||||
def _add_user(caller, user, state):
|
||||
def user_requested_access(user):
|
||||
"""
|
||||
User has requested course creator access.
|
||||
|
||||
This changes the user state to CourseCreator.PENDING, unless the user
|
||||
state is already CourseCreator.GRANTED, in which case this method is a no-op.
|
||||
"""
|
||||
user = CourseCreator.objects.get(user=user)
|
||||
if user.state != CourseCreator.GRANTED:
|
||||
user.state = CourseCreator.PENDING
|
||||
user.save()
|
||||
|
||||
|
||||
def _add_user(user, state):
|
||||
"""
|
||||
Adds a user to the course creator table with the specified state.
|
||||
|
||||
If the user is already in the table, this method is a no-op
|
||||
(state will not be changed).
|
||||
"""
|
||||
if not caller.is_active or not caller.is_authenticated or not caller.is_staff:
|
||||
raise PermissionDenied
|
||||
Returns True if user was added to table, else False.
|
||||
|
||||
if CourseCreator.objects.filter(user=user).count() == 0:
|
||||
If the user is already in the table, this method is a no-op
|
||||
(state will not be changed, method will return False).
|
||||
|
||||
If the user is marked as is_staff, this method is a no-op (False will be returned).
|
||||
"""
|
||||
if not user.is_staff and CourseCreator.objects.filter(user=user).count() == 0:
|
||||
entry = CourseCreator(user=user, state=state)
|
||||
entry.save()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -72,6 +72,9 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
# Use the auto_auth workflow for creating users and logging them in
|
||||
MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
|
||||
|
||||
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
|
||||
INSTALLED_APPS += ('lettuce.django',)
|
||||
LETTUCE_APPS = ('contentstore',)
|
||||
|
||||
24
cms/envs/aws_migrate.py
Normal file
24
cms/envs/aws_migrate.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
A Django settings file for use on AWS while running
|
||||
database migrations, since we don't want to normally run the
|
||||
LMS with enough privileges to modify the database schema.
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=W0401, W0614
|
||||
|
||||
# Import everything from .aws so that our settings are based on those.
|
||||
from .aws import *
|
||||
import os
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
USER = os.environ.get('DB_MIGRATION_USER', 'root')
|
||||
PASSWORD = os.environ.get('DB_MIGRATION_PASS', None)
|
||||
|
||||
if not PASSWORD:
|
||||
raise ImproperlyConfigured("No database password was provided for running "
|
||||
"migrations. This is fatal.")
|
||||
|
||||
DATABASES['default']['USER'] = USER
|
||||
DATABASES['default']['PASSWORD'] = PASSWORD
|
||||
@@ -42,8 +42,8 @@ MITX_FEATURES = {
|
||||
# do not display video when running automated acceptance tests
|
||||
'STUB_VIDEO_FOR_TESTING': False,
|
||||
|
||||
# email address for staff (eg to request course creation)
|
||||
'STAFF_EMAIL': '',
|
||||
# email address for studio staff (eg to request course creation)
|
||||
'STUDIO_REQUEST_EMAIL': '',
|
||||
|
||||
'STUDIO_NPS_SURVEY': True,
|
||||
|
||||
@@ -62,9 +62,6 @@ MITX_FEATURES = {
|
||||
}
|
||||
ENABLE_JASMINE = False
|
||||
|
||||
# needed to use lms student app
|
||||
GENERATE_RANDOM_USER_CREDENTIALS = False
|
||||
|
||||
|
||||
############################# SET PATH INFORMATION #############################
|
||||
PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /mitx/cms
|
||||
@@ -108,7 +105,12 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
'django.core.context_processors.static',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.contrib.auth.context_processors.auth', # this is required for admin
|
||||
'django.core.context_processors.csrf', # necessary for csrf protection
|
||||
'django.core.context_processors.csrf'
|
||||
)
|
||||
|
||||
# use the ratelimit backend to prevent brute force attacks
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'ratelimitbackend.backends.RateLimitModelBackend',
|
||||
)
|
||||
|
||||
LMS_BASE = None
|
||||
@@ -141,8 +143,8 @@ MIDDLEWARE_CLASSES = (
|
||||
'request_cache.middleware.RequestCache',
|
||||
'django.middleware.cache.UpdateCacheMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'method_override.middleware.MethodOverrideMiddleware',
|
||||
|
||||
# Instead of AuthenticationMiddleware, we use a cache-backed version
|
||||
@@ -155,7 +157,10 @@ MIDDLEWARE_CLASSES = (
|
||||
# Detects user-requested locale from 'accept-language' header in http request
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
|
||||
'django.middleware.transaction.TransactionMiddleware'
|
||||
'django.middleware.transaction.TransactionMiddleware',
|
||||
|
||||
# catches any uncaught RateLimitExceptions and returns a 403 instead of a 500
|
||||
'ratelimitbackend.middleware.RateLimitMiddleware',
|
||||
)
|
||||
|
||||
############################ SIGNAL HANDLERS ################################
|
||||
@@ -179,9 +184,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
DEFAULT_FROM_EMAIL = 'registration@edx.org'
|
||||
DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org'
|
||||
SERVER_EMAIL = 'devops@edx.org'
|
||||
ADMINS = (
|
||||
('edX Admins', 'admin@edx.org'),
|
||||
)
|
||||
ADMINS = ()
|
||||
MANAGERS = ADMINS
|
||||
|
||||
# Static content
|
||||
@@ -193,8 +196,8 @@ STATICFILES_DIRS = [
|
||||
COMMON_ROOT / "static",
|
||||
PROJECT_ROOT / "static",
|
||||
|
||||
# This is how you would use the textbook images locally
|
||||
# ("book", ENV_ROOT / "book_images")
|
||||
# This is how you would use the textbook images locally
|
||||
# ("book", ENV_ROOT / "book_images")
|
||||
]
|
||||
|
||||
# Locale/Internationalization
|
||||
@@ -241,10 +244,11 @@ PIPELINE_JS = {
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') +
|
||||
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js')
|
||||
) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js',
|
||||
'js/models/course.js',
|
||||
'js/models/section.js', 'js/views/section.js',
|
||||
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
|
||||
'js/models/textbook.js', 'js/views/textbook.js',
|
||||
'js/views/assets.js'],
|
||||
'js/views/assets.js', 'js/utility.js'],
|
||||
'output_filename': 'js/cms-application.js',
|
||||
'test_order': 0
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ sessions. Assumes structure:
|
||||
from .common import *
|
||||
import os
|
||||
from path import path
|
||||
from warnings import filterwarnings
|
||||
|
||||
# Nose Test Runner
|
||||
INSTALLED_APPS += ('django_nose',)
|
||||
@@ -124,6 +125,9 @@ CACHES = {
|
||||
}
|
||||
}
|
||||
|
||||
# hide ratelimit warnings while running tests
|
||||
filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit')
|
||||
|
||||
################################# CELERY ######################################
|
||||
|
||||
CELERY_ALWAYS_EAGER = True
|
||||
|
||||
1
cms/static/coffee/fixtures/metadata-list-entry.underscore
Symbolic link
1
cms/static/coffee/fixtures/metadata-list-entry.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/js/metadata-list-entry.underscore
|
||||
33
cms/static/coffee/fixtures/tabs-edit.html
Normal file
33
cms/static/coffee/fixtures/tabs-edit.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="base_wrapper">
|
||||
<section class="editor-with-tabs">
|
||||
<div class="wrapper-comp-editor" id="editor-tab-id" data-html_id='test_id'>
|
||||
<div class="edit-header">
|
||||
<ul class="editor-tabs">
|
||||
<li class="inner_tab_wrap"><a href="#tab-0" class="tab">Tab 0 Editor</a></li>
|
||||
<li class="inner_tab_wrap"><a href="#tab-1" class="tab">Tab 1 Transcripts</a></li>
|
||||
<li class="inner_tab_wrap" id="settings"><a href="#tab-2" class="tab">Tab 2 Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tabs-wrapper">
|
||||
<div class="component-tab" id="tab-0">
|
||||
<textarea name="" class="edit-box">XML Editor Text</textarea>
|
||||
</div>
|
||||
<div class="component-tab" id="tab-1">
|
||||
Transcripts
|
||||
</div>
|
||||
<div class="component-tab" id="tab-2">
|
||||
Subtitles
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-comp-settings">
|
||||
<ul>
|
||||
<li id="editor-mode"><a>Editor</a></li>
|
||||
<li id="settings-mode"><a>Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="component-edit-header" style="display: block"/>
|
||||
</div>
|
||||
|
||||
9
cms/static/coffee/spec/models/course_spec.coffee
Normal file
9
cms/static/coffee/spec/models/course_spec.coffee
Normal file
@@ -0,0 +1,9 @@
|
||||
describe "CMS.Models.Course", ->
|
||||
describe "basic", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.Course({
|
||||
name: "Greek Hero"
|
||||
})
|
||||
|
||||
it "should take a name argument", ->
|
||||
expect(@model.get("name")).toEqual("Greek Hero")
|
||||
95
cms/static/coffee/spec/tabs/edit.coffee
Normal file
95
cms/static/coffee/spec/tabs/edit.coffee
Normal file
@@ -0,0 +1,95 @@
|
||||
describe "TabsEditingDescriptor", ->
|
||||
beforeEach ->
|
||||
@isInactiveClass = "is-inactive"
|
||||
@isCurrent = "current"
|
||||
loadFixtures 'tabs-edit.html'
|
||||
@descriptor = new TabsEditingDescriptor($('.base_wrapper'))
|
||||
@html_id = 'test_id'
|
||||
@tab_0_switch = jasmine.createSpy('tab_0_switch');
|
||||
@tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate');
|
||||
@tab_1_switch = jasmine.createSpy('tab_1_switch');
|
||||
@tab_1_modelUpdate = jasmine.createSpy('tab_1_modelUpdate');
|
||||
TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate)
|
||||
TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 0 Editor', @tab_0_switch)
|
||||
TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 1 Transcripts', @tab_1_modelUpdate)
|
||||
TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 1 Transcripts', @tab_1_switch)
|
||||
|
||||
spyOn($.fn, 'hide').andCallThrough()
|
||||
spyOn($.fn, 'show').andCallThrough()
|
||||
spyOn(TabsEditingDescriptor.Model, 'initialize')
|
||||
spyOn(TabsEditingDescriptor.Model, 'updateValue')
|
||||
|
||||
afterEach ->
|
||||
TabsEditingDescriptor.Model.modules= {}
|
||||
|
||||
describe "constructor", ->
|
||||
it "first tab should be visible", ->
|
||||
expect(@descriptor.$tabs.first()).toHaveClass(@isCurrent)
|
||||
expect(@descriptor.$content.first()).not.toHaveClass(@isInactiveClass)
|
||||
|
||||
describe "onSwitchEditor", ->
|
||||
it "switching tabs changes styles", ->
|
||||
@descriptor.$tabs.eq(1).trigger("click")
|
||||
expect(@descriptor.$tabs.eq(0)).not.toHaveClass(@isCurrent)
|
||||
expect(@descriptor.$content.eq(0)).toHaveClass(@isInactiveClass)
|
||||
expect(@descriptor.$tabs.eq(1)).toHaveClass(@isCurrent)
|
||||
expect(@descriptor.$content.eq(1)).not.toHaveClass(@isInactiveClass)
|
||||
expect(@tab_1_switch).toHaveBeenCalled()
|
||||
|
||||
it "if click on current tab, nothing should happen", ->
|
||||
spyOn($.fn, 'trigger').andCallThrough()
|
||||
currentTab = @descriptor.$tabs.filter('.' + @isCurrent)
|
||||
@descriptor.$tabs.eq(0).trigger("click")
|
||||
expect(@descriptor.$tabs.filter('.' + @isCurrent)).toEqual(currentTab)
|
||||
expect($.fn.trigger.calls.length).toEqual(1)
|
||||
|
||||
it "onSwitch function call", ->
|
||||
@descriptor.$tabs.eq(1).trigger("click")
|
||||
expect(TabsEditingDescriptor.Model.updateValue).toHaveBeenCalled()
|
||||
expect(@tab_1_switch).toHaveBeenCalled()
|
||||
|
||||
describe "save", ->
|
||||
it "function for current tab should be called", ->
|
||||
@descriptor.$tabs.eq(1).trigger("click")
|
||||
data = @descriptor.save().data
|
||||
expect(@tab_1_modelUpdate).toHaveBeenCalled()
|
||||
|
||||
it "detach click event", ->
|
||||
spyOn($.fn, "off")
|
||||
@descriptor.save()
|
||||
expect($.fn.off).toHaveBeenCalledWith(
|
||||
'click',
|
||||
'.editor-tabs .tab',
|
||||
@descriptor.onSwitchEditor
|
||||
)
|
||||
|
||||
describe "editor/settings header", ->
|
||||
it "is hidden", ->
|
||||
expect(@descriptor.element.find(".component-edit-header").css('display')).toEqual('none')
|
||||
|
||||
describe "TabsEditingDescriptor special save cases", ->
|
||||
beforeEach ->
|
||||
@isInactiveClass = "is-inactive"
|
||||
@isCurrent = "current"
|
||||
loadFixtures 'tabs-edit.html'
|
||||
@descriptor = new window.TabsEditingDescriptor($('.base_wrapper'))
|
||||
@html_id = 'test_id'
|
||||
|
||||
describe "save", ->
|
||||
it "case: no init", ->
|
||||
data = @descriptor.save().data
|
||||
expect(data).toEqual(null)
|
||||
|
||||
it "case: no function in model update", ->
|
||||
TabsEditingDescriptor.Model.initialize(@html_id)
|
||||
data = @descriptor.save().data
|
||||
expect(data).toEqual(null)
|
||||
|
||||
it "case: no function in model update, but value presented", ->
|
||||
@tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate').andReturn(1)
|
||||
TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate)
|
||||
@descriptor.$tabs.eq(1).trigger("click")
|
||||
expect(@tab_0_modelUpdate).toHaveBeenCalled()
|
||||
data = @descriptor.save().data
|
||||
expect(data).toEqual(1)
|
||||
|
||||
@@ -3,12 +3,14 @@ describe "Test Metadata Editor", ->
|
||||
numberEntryTemplate = readFixtures('metadata-number-entry.underscore')
|
||||
stringEntryTemplate = readFixtures('metadata-string-entry.underscore')
|
||||
optionEntryTemplate = readFixtures('metadata-option-entry.underscore')
|
||||
listEntryTemplate = readFixtures('metadata-list-entry.underscore')
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "metadata-editor-tpl", type: "text/template"}).text(editorTemplate))
|
||||
appendSetFixtures($("<script>", {id: "metadata-number-entry", type: "text/template"}).text(numberEntryTemplate))
|
||||
appendSetFixtures($("<script>", {id: "metadata-string-entry", type: "text/template"}).text(stringEntryTemplate))
|
||||
appendSetFixtures($("<script>", {id: "metadata-option-entry", type: "text/template"}).text(optionEntryTemplate))
|
||||
appendSetFixtures($("<script>", {id: "metadata-list-entry", type: "text/template"}).text(listEntryTemplate))
|
||||
|
||||
genericEntry = {
|
||||
default_value: 'default value',
|
||||
@@ -62,6 +64,18 @@ describe "Test Metadata Editor", ->
|
||||
value: 10.2
|
||||
}
|
||||
|
||||
listEntry = {
|
||||
default_value: ["a thing", "another thing"],
|
||||
display_name: "List",
|
||||
explicitly_set: false,
|
||||
field_name: "list",
|
||||
help: "A list of things.",
|
||||
inheritable: false,
|
||||
options: [],
|
||||
type: CMS.Models.Metadata.LIST_TYPE,
|
||||
value: ["the first display value", "the second"]
|
||||
}
|
||||
|
||||
# Test for the editor that creates the individual views.
|
||||
describe "CMS.Views.Metadata.Editor creates editors for each field", ->
|
||||
beforeEach ->
|
||||
@@ -84,16 +98,18 @@ describe "Test Metadata Editor", ->
|
||||
{"display_name": "Never", "value": "never"}],
|
||||
type: "unknown type",
|
||||
value: null
|
||||
}
|
||||
},
|
||||
listEntry
|
||||
]
|
||||
)
|
||||
|
||||
it "creates child views on initialize, and sorts them alphabetically", ->
|
||||
view = new CMS.Views.Metadata.Editor({collection: @model})
|
||||
childModels = view.collection.models
|
||||
expect(childModels.length).toBe(5)
|
||||
childViews = view.$el.find('.setting-input')
|
||||
expect(childViews.length).toBe(5)
|
||||
expect(childModels.length).toBe(6)
|
||||
# Be sure to check list view as well as other input types
|
||||
childViews = view.$el.find('.setting-input, .list-settings')
|
||||
expect(childViews.length).toBe(6)
|
||||
|
||||
verifyEntry = (index, display_name, type) ->
|
||||
expect(childModels[index].get('display_name')).toBe(display_name)
|
||||
@@ -101,9 +117,10 @@ describe "Test Metadata Editor", ->
|
||||
|
||||
verifyEntry(0, 'Display Name', 'text')
|
||||
verifyEntry(1, 'Inputs', 'number')
|
||||
verifyEntry(2, 'Show Answer', 'select-one')
|
||||
verifyEntry(3, 'Unknown', 'text')
|
||||
verifyEntry(4, 'Weight', 'number')
|
||||
verifyEntry(2, 'List', '')
|
||||
verifyEntry(3, 'Show Answer', 'select-one')
|
||||
verifyEntry(4, 'Unknown', 'text')
|
||||
verifyEntry(5, 'Weight', 'number')
|
||||
|
||||
it "returns its display name", ->
|
||||
view = new CMS.Views.Metadata.Editor({collection: @model})
|
||||
@@ -146,27 +163,27 @@ describe "Test Metadata Editor", ->
|
||||
# Tests for individual views.
|
||||
assertInputType = (view, expectedType) ->
|
||||
input = view.$el.find('.setting-input')
|
||||
expect(input.length).toBe(1)
|
||||
expect(input[0].type).toBe(expectedType)
|
||||
expect(input.length).toEqual(1)
|
||||
expect(input[0].type).toEqual(expectedType)
|
||||
|
||||
assertValueInView = (view, expectedValue) ->
|
||||
expect(view.getValueFromEditor()).toBe(expectedValue)
|
||||
expect(view.getValueFromEditor()).toEqual(expectedValue)
|
||||
|
||||
assertCanUpdateView = (view, newValue) ->
|
||||
view.setValueInEditor(newValue)
|
||||
expect(view.getValueFromEditor()).toBe(newValue)
|
||||
expect(view.getValueFromEditor()).toEqual(newValue)
|
||||
|
||||
assertClear = (view, modelValue, editorValue=modelValue) ->
|
||||
view.clear()
|
||||
expect(view.model.getValue()).toBe(null)
|
||||
expect(view.model.getDisplayValue()).toBe(modelValue)
|
||||
expect(view.getValueFromEditor()).toBe(editorValue)
|
||||
expect(view.model.getDisplayValue()).toEqual(modelValue)
|
||||
expect(view.getValueFromEditor()).toEqual(editorValue)
|
||||
|
||||
assertUpdateModel = (view, originalValue, newValue) ->
|
||||
view.setValueInEditor(newValue)
|
||||
expect(view.model.getValue()).toBe(originalValue)
|
||||
expect(view.model.getValue()).toEqual(originalValue)
|
||||
view.updateModel()
|
||||
expect(view.model.getValue()).toBe(newValue)
|
||||
expect(view.model.getValue()).toEqual(newValue)
|
||||
|
||||
describe "CMS.Views.Metadata.String is a basic string input with clear functionality", ->
|
||||
beforeEach ->
|
||||
@@ -298,3 +315,45 @@ describe "Test Metadata Editor", ->
|
||||
|
||||
verifyDisallowedChars(@integerView)
|
||||
verifyDisallowedChars(@floatView)
|
||||
|
||||
describe "CMS.Views.Metadata.List allows the user to enter an ordered list of strings", ->
|
||||
beforeEach ->
|
||||
listModel = new CMS.Models.Metadata(listEntry)
|
||||
@listView = new CMS.Views.Metadata.List({model: listModel})
|
||||
@el = @listView.$el
|
||||
|
||||
it "returns the initial value upon initialization", ->
|
||||
assertValueInView(@listView, ['the first display value', 'the second'])
|
||||
|
||||
it "updates its value correctly", ->
|
||||
assertCanUpdateView(@listView, ['a new item', 'another new item', 'a third'])
|
||||
|
||||
it "has a clear method to revert to the model default", ->
|
||||
assertClear(@listView, ['a thing', 'another thing'])
|
||||
|
||||
it "has an update model method", ->
|
||||
assertUpdateModel(@listView, null, ['a new value'])
|
||||
|
||||
it "can add an entry", ->
|
||||
expect(@listView.model.get('value').length).toEqual(2)
|
||||
@el.find('.create-setting').click()
|
||||
expect(@el.find('input.input').length).toEqual(3)
|
||||
|
||||
it "can remove an entry", ->
|
||||
expect(@listView.model.get('value').length).toEqual(2)
|
||||
@el.find('.remove-setting').first().click()
|
||||
expect(@listView.model.get('value').length).toEqual(1)
|
||||
|
||||
it "only allows one blank entry at a time", ->
|
||||
expect(@el.find('input').length).toEqual(2)
|
||||
@el.find('.create-setting').click()
|
||||
@el.find('.create-setting').click()
|
||||
expect(@el.find('input').length).toEqual(3)
|
||||
|
||||
it "re-enables the add setting button after entering a new value", ->
|
||||
expect(@el.find('input').length).toEqual(2)
|
||||
@el.find('.create-setting').click()
|
||||
expect(@el.find('.create-setting')).toHaveClass('is-disabled')
|
||||
@el.find('input').last().val('third setting')
|
||||
@el.find('input').last().trigger('input')
|
||||
expect(@el.find('.create-setting')).not.toHaveClass('is-disabled')
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
describe "Course Overview", ->
|
||||
|
||||
beforeEach ->
|
||||
appendSetFixtures """
|
||||
<script src="/static/js/vendor/date.js"></script>
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<script type="text/javascript" src="/jsi18n/"></script>
|
||||
"""
|
||||
_.each ["/static/js/vendor/date.js", "/static/js/vendor/timepicker/jquery.timepicker.js", "/jsi18n/"], (path) ->
|
||||
appendSetFixtures """
|
||||
<script type="text/javascript" src="#{path}"></script>
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<div class="section-published-date">
|
||||
<span class="published-status">
|
||||
<strong>Will Release:</strong> 06/12/2013 at 04:00 UTC
|
||||
</span>
|
||||
<a href="#" class="edit-button" "="" data-date="06/12/2013" data-time="04:00" data-id="i4x://pfogg/42/chapter/d6b47f7b084f49debcaf67fe5436c8e2">Edit</a>
|
||||
<a href="#" class="edit-button" data-date="06/12/2013" data-time="04:00" data-id="i4x://pfogg/42/chapter/d6b47f7b084f49debcaf67fe5436c8e2">Edit</a>
|
||||
</div>
|
||||
"""#"
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<div class="edit-subsection-publish-settings">
|
||||
@@ -38,7 +35,7 @@ describe "Course Overview", ->
|
||||
<a href="#" class="save-button">Save</a><a href="#" class="cancel-button">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
"""#"
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<section class="courseware-section branch" data-id="a-location-goes-here">
|
||||
@@ -46,12 +43,13 @@ describe "Course Overview", ->
|
||||
<a href="#" class="delete-section-button"></a>
|
||||
</li>
|
||||
</section>
|
||||
"""#"
|
||||
"""
|
||||
|
||||
spyOn(window, 'saveSetSectionScheduleDate').andCallThrough()
|
||||
# Have to do this here, as it normally gets bound in document.ready()
|
||||
$('a.save-button').click(saveSetSectionScheduleDate)
|
||||
$('a.delete-section-button').click(deleteSection)
|
||||
$(".edit-subsection-publish-settings .start-date").datepicker()
|
||||
|
||||
@notificationSpy = spyOn(CMS.Views.Notification.Mini.prototype, 'show').andCallThrough()
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track'])
|
||||
|
||||
@@ -41,7 +41,12 @@ class CMS.Views.UnitEdit extends Backbone.View
|
||||
id: unit_location_analytics
|
||||
|
||||
payload = children : @components()
|
||||
options = success : => @model.unset('children')
|
||||
saving = new CMS.Views.Notification.Mini
|
||||
title: gettext('Saving') + '…'
|
||||
saving.show()
|
||||
options = success : =>
|
||||
@model.unset('children')
|
||||
saving.hide()
|
||||
@model.save(payload, options)
|
||||
helper: 'clone'
|
||||
opacity: '0.5'
|
||||
|
||||
@@ -60,12 +60,10 @@ $(document).ready(function() {
|
||||
$('.nav-dd .nav-item .title').removeClass('is-selected');
|
||||
});
|
||||
|
||||
$('.nav-dd .nav-item .title').click(function(e) {
|
||||
$('.nav-dd .nav-item').click(function(e) {
|
||||
|
||||
$subnav = $(this).parent().find('.wrapper-nav-sub');
|
||||
$title = $(this).parent().find('.title');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$subnav = $(this).find('.wrapper-nav-sub');
|
||||
$title = $(this).find('.title');
|
||||
|
||||
if ($subnav.hasClass('is-shown')) {
|
||||
$subnav.removeClass('is-shown');
|
||||
@@ -75,6 +73,9 @@ $(document).ready(function() {
|
||||
$('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown');
|
||||
$title.addClass('is-selected');
|
||||
$subnav.addClass('is-shown');
|
||||
// if propogation is not stopped, the event will bubble up to the
|
||||
// body element, which will close the dropdown.
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -252,21 +253,20 @@ function syncReleaseDate(e) {
|
||||
$("#start_time").val("");
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeVals(date_val, time_val) {
|
||||
if (date_val != '') {
|
||||
if (time_val == '') time_val = '00:00';
|
||||
|
||||
return new Date(date_val + " " + time_val + "Z");
|
||||
function getDatetime(datepickerInput, timepickerInput) {
|
||||
// given a pair of inputs (datepicker and timepicker), return a JS Date
|
||||
// object that corresponds to the datetime that they represent. Assume
|
||||
// UTC timezone, NOT the timezone of the user's browser.
|
||||
var date = $(datepickerInput).datepicker("getDate");
|
||||
var time = $(timepickerInput).timepicker("getTime");
|
||||
if(date && time) {
|
||||
return new Date(Date.UTC(
|
||||
date.getFullYear(), date.getMonth(), date.getDate(),
|
||||
time.getHours(), time.getMinutes()
|
||||
));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
else return null;
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeInputs(date_id, time_id) {
|
||||
var input_date = $('#' + date_id).val();
|
||||
var input_time = $('#' + time_id).val();
|
||||
|
||||
return getEdxTimeFromDateTimeVals(input_date, input_time);
|
||||
}
|
||||
|
||||
function autosaveInput(e) {
|
||||
@@ -306,9 +306,17 @@ function saveSubsection() {
|
||||
metadata[$(el).data("metadata-name")] = el.value;
|
||||
}
|
||||
|
||||
// Piece back together the date/time UI elements into one date/time string
|
||||
metadata['start'] = getEdxTimeFromDateTimeInputs('start_date', 'start_time');
|
||||
metadata['due'] = getEdxTimeFromDateTimeInputs('due_date', 'due_time');
|
||||
// get datetimes for start and due, stick into metadata
|
||||
_(["start", "due"]).each(function(name) {
|
||||
|
||||
var datetime = getDatetime(
|
||||
document.getElementById(name+"_date"),
|
||||
document.getElementById(name+"_time")
|
||||
);
|
||||
// if datetime is null, we want to set that in metadata anyway;
|
||||
// its an indication to the server to clear the datetime in the DB
|
||||
metadata[name] = datetime;
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
@@ -596,11 +604,9 @@ function cancelNewSection(e) {
|
||||
|
||||
function addNewCourse(e) {
|
||||
e.preventDefault();
|
||||
|
||||
$(e.target).hide();
|
||||
var $newCourse = $($('#new-course-template').html());
|
||||
$('.new-course-button').addClass('is-disabled');
|
||||
var $newCourse = $('.wrapper-create-course').addClass('is-shown');
|
||||
var $cancelButton = $newCourse.find('.new-course-cancel');
|
||||
$('.inner-wrapper').prepend($newCourse);
|
||||
$newCourse.find('.new-course-name').focus().select();
|
||||
$newCourse.find('form').bind('submit', saveNewCourse);
|
||||
$cancelButton.bind('click', cancelNewCourse);
|
||||
@@ -612,41 +618,97 @@ function addNewCourse(e) {
|
||||
function saveNewCourse(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $newCourse = $(this).closest('.new-course');
|
||||
var org = $newCourse.find('.new-course-org').val();
|
||||
var number = $newCourse.find('.new-course-number').val();
|
||||
var display_name = $newCourse.find('.new-course-name').val();
|
||||
var $newCourseForm = $(this).closest('#create-course-form');
|
||||
var display_name = $newCourseForm.find('.new-course-name').val();
|
||||
var org = $newCourseForm.find('.new-course-org').val();
|
||||
var number = $newCourseForm.find('.new-course-number').val();
|
||||
var run = $newCourseForm.find('.new-course-run').val();
|
||||
|
||||
if (org == '' || number == '' || display_name == '') {
|
||||
alert(gettext('You must specify all fields in order to create a new course.'));
|
||||
return;
|
||||
var required_field_text = gettext('Required field');
|
||||
|
||||
var display_name_errMsg = (display_name === '') ? required_field_text : null;
|
||||
var org_errMsg = (org === '') ? required_field_text : null;
|
||||
var number_errMsg = (number === '') ? required_field_text : null;
|
||||
var run_errMsg = (run === '') ? required_field_text : null;
|
||||
|
||||
var bInErr = (display_name_errMsg || org_errMsg || number_errMsg || run_errMsg);
|
||||
|
||||
// check for suitable encoding
|
||||
if (!bInErr) {
|
||||
var encoding_errMsg = gettext('Please do not use any spaces or special characters in this field.');
|
||||
|
||||
if (encodeURIComponent(org) != org)
|
||||
org_errMsg = encoding_errMsg;
|
||||
if (encodeURIComponent(number) != number)
|
||||
number_errMsg = encoding_errMsg;
|
||||
if (encodeURIComponent(run) != run)
|
||||
run_errMsg = encoding_errMsg;
|
||||
|
||||
bInErr = (org_errMsg || number_errMsg || run_errMsg);
|
||||
}
|
||||
|
||||
var header_err_msg = (bInErr) ? gettext('Please correct the fields below.') : null;
|
||||
|
||||
var setNewCourseErrMsgs = function(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg) {
|
||||
if (header_err_msg) {
|
||||
$('.wrapper-create-course').addClass('has-errors');
|
||||
$('.wrap-error').addClass('is-shown');
|
||||
$('#course_creation_error').html('<p>' + header_err_msg + '</p>');
|
||||
} else {
|
||||
$('.wrap-error').removeClass('is-shown');
|
||||
$('#course_creation_error').html('');
|
||||
}
|
||||
|
||||
var setNewCourseFieldInErr = function(el, msg) {
|
||||
el.children('.tip-error').remove();
|
||||
if (msg !== null && msg !== '') {
|
||||
el.addClass('error');
|
||||
el.append('<span class="tip tip-error">' + msg + '</span>');
|
||||
} else {
|
||||
el.removeClass('error');
|
||||
}
|
||||
};
|
||||
|
||||
setNewCourseFieldInErr($('#field-course-name'), display_name_errMsg);
|
||||
setNewCourseFieldInErr($('#field-organization'), org_errMsg);
|
||||
setNewCourseFieldInErr($('#field-course-number'), number_errMsg);
|
||||
setNewCourseFieldInErr($('#field-course-run'), run_errMsg);
|
||||
};
|
||||
|
||||
setNewCourseErrMsgs(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg);
|
||||
|
||||
if (bInErr)
|
||||
return;
|
||||
|
||||
analytics.track('Created a Course', {
|
||||
'org': org,
|
||||
'number': number,
|
||||
'display_name': display_name
|
||||
'display_name': display_name,
|
||||
'run': run
|
||||
});
|
||||
|
||||
$.post('/create_new_course', {
|
||||
'org': org,
|
||||
'number': number,
|
||||
'display_name': display_name
|
||||
},
|
||||
|
||||
function(data) {
|
||||
if (data.id != undefined) {
|
||||
window.location = '/' + data.id.replace(/.*:\/\//, '');
|
||||
} else if (data.ErrMsg != undefined) {
|
||||
alert(data.ErrMsg);
|
||||
'org': org,
|
||||
'number': number,
|
||||
'display_name': display_name,
|
||||
'run': run
|
||||
},
|
||||
function(data) {
|
||||
if (data.id !== undefined) {
|
||||
window.location = '/' + data.id.replace(/.*:\/\//, '');
|
||||
} else if (data.ErrMsg !== undefined) {
|
||||
var orgErrMsg = (data.OrgErrMsg !== undefined) ? data.OrgErrMsg : null;
|
||||
var courseErrMsg = (data.CourseErrMsg !== undefined) ? data.CourseErrMsg : null;
|
||||
setNewCourseErrMsgs(data.ErrMsg, null, orgErrMsg, courseErrMsg, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
function cancelNewCourse(e) {
|
||||
e.preventDefault();
|
||||
$('.new-course-button').show();
|
||||
$(this).parents('section.new-course').remove();
|
||||
$('.new-course-button').removeClass('is-disabled');
|
||||
$('.wrapper-create-course').removeClass('is-shown');
|
||||
}
|
||||
|
||||
function addNewSubsection(e) {
|
||||
@@ -717,21 +779,21 @@ function cancelSetSectionScheduleDate(e) {
|
||||
function saveSetSectionScheduleDate(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var input_date = $('.edit-subsection-publish-settings .start-date').val();
|
||||
var input_time = $('.edit-subsection-publish-settings .start-time').val();
|
||||
|
||||
var start = getEdxTimeFromDateTimeVals(input_date, input_time);
|
||||
var datetime = getDatetime(
|
||||
$('.edit-subsection-publish-settings .start-date'),
|
||||
$('.edit-subsection-publish-settings .start-time')
|
||||
);
|
||||
|
||||
var id = $modal.attr('data-id');
|
||||
|
||||
analytics.track('Edited Section Release Date', {
|
||||
'course': course_location_analytics,
|
||||
'id': id,
|
||||
'start': start
|
||||
'start': datetime
|
||||
});
|
||||
|
||||
var saving = new CMS.Views.Notification.Mini({
|
||||
title: gettext("Saving") + "…",
|
||||
title: gettext("Saving") + "…"
|
||||
});
|
||||
saving.show();
|
||||
// call into server to commit the new order
|
||||
@@ -743,20 +805,29 @@ function saveSetSectionScheduleDate(e) {
|
||||
data: JSON.stringify({
|
||||
'id': id,
|
||||
'metadata': {
|
||||
'start': start
|
||||
'start': datetime
|
||||
}
|
||||
})
|
||||
}).success(function() {
|
||||
var pad2 = function(number) {
|
||||
// pad a number to two places: useful for formatting months, days, hours, etc
|
||||
// when displaying a date/time
|
||||
return (number < 10 ? '0' : '') + number;
|
||||
};
|
||||
|
||||
var $thisSection = $('.courseware-section[data-id="' + id + '"]');
|
||||
var html = _.template(
|
||||
'<span class="published-status">' +
|
||||
'<strong>' + gettext("Will Release:") + ' </strong>' +
|
||||
gettext("<%= date %> at <%= time %> UTC") +
|
||||
gettext("{month}/{day}/{year} at {hour}:{minute} UTC") +
|
||||
'</span>' +
|
||||
'<a href="#" class="edit-button" data-date="<%= date %>" data-time="<%= time %>" data-id="<%= id %>">' +
|
||||
'<a href="#" class="edit-button" data-date="{month}/{day}/{year}" data-time="{hour}:{minute}" data-id="{id}">' +
|
||||
gettext("Edit") +
|
||||
'</a>',
|
||||
{date: input_date, time: input_time, id: id});
|
||||
{year: datetime.getUTCFullYear(), month: pad2(datetime.getUTCMonth() + 1), day: pad2(datetime.getUTCDate()),
|
||||
hour: pad2(datetime.getUTCHours()), minute: pad2(datetime.getUTCMinutes()),
|
||||
id: id},
|
||||
{interpolate: /\{(.+?)\}/g});
|
||||
$thisSection.find('.section-published-date').html(html);
|
||||
hideModal();
|
||||
saving.hide();
|
||||
|
||||
10
cms/static/js/models/course.js
Normal file
10
cms/static/js/models/course.js
Normal file
@@ -0,0 +1,10 @@
|
||||
CMS.Models.Course = Backbone.Model.extend({
|
||||
defaults: {
|
||||
"name": ""
|
||||
},
|
||||
validate: function(attrs, options) {
|
||||
if (!attrs.name) {
|
||||
return gettext("You must specify a name");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -111,3 +111,4 @@ CMS.Models.Metadata.SELECT_TYPE = "Select";
|
||||
CMS.Models.Metadata.INTEGER_TYPE = "Integer";
|
||||
CMS.Models.Metadata.FLOAT_TYPE = "Float";
|
||||
CMS.Models.Metadata.GENERIC_TYPE = "Generic";
|
||||
CMS.Models.Metadata.LIST_TYPE = "List";
|
||||
|
||||
@@ -96,7 +96,7 @@ function displayFinishedUpload(xhr) {
|
||||
}
|
||||
|
||||
var resp = JSON.parse(xhr.responseText);
|
||||
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
|
||||
$('.upload-modal .embeddable-xml-input').val(resp.portable_url);
|
||||
$('.upload-modal .embeddable').show();
|
||||
$('.upload-modal .file-name').hide();
|
||||
$('.upload-modal .progress-fill').html(resp.msg);
|
||||
|
||||
@@ -34,16 +34,8 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
var self = this;
|
||||
// instantiates an editor template for each update in the collection
|
||||
window.templateLoader.loadRemoteTemplate("course_info_update",
|
||||
// TODO Where should the template reside? how to use the static.url to create the path?
|
||||
"/static/client_templates/course_info_update.html",
|
||||
function (raw_template) {
|
||||
self.template = _.template(raw_template);
|
||||
self.render();
|
||||
}
|
||||
);
|
||||
this.template = _.template($("#course_info_update-tpl").text());
|
||||
this.render();
|
||||
// when the client refetches the updates as a whole, re-render them
|
||||
this.listenTo(this.collection, 'reset', this.render);
|
||||
},
|
||||
@@ -105,7 +97,19 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
|
||||
var targetModel = this.eventModel(event);
|
||||
targetModel.set({ date : this.dateEntry(event).val(), content : this.$codeMirror.getValue() });
|
||||
// push change to display, hide the editor, submit the change
|
||||
targetModel.save({});
|
||||
var saving = new CMS.Views.Notification.Mini({
|
||||
title: gettext('Saving') + '…'
|
||||
});
|
||||
saving.show();
|
||||
var ele = this.modelDom(event);
|
||||
targetModel.save({}, {
|
||||
success: function() {
|
||||
saving.hide();
|
||||
},
|
||||
error: function() {
|
||||
ele.remove();
|
||||
}
|
||||
});
|
||||
this.closeEditor(this);
|
||||
|
||||
analytics.track('Saved Course Update', {
|
||||
@@ -148,29 +152,48 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
|
||||
onDelete: function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!confirm('Are you sure you want to delete this update? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
analytics.track('Deleted Course Update', {
|
||||
'course': course_location_analytics,
|
||||
'date': this.dateEntry(event).val()
|
||||
});
|
||||
|
||||
var self = this;
|
||||
var targetModel = this.eventModel(event);
|
||||
this.modelDom(event).remove();
|
||||
var cacheThis = this;
|
||||
targetModel.destroy({
|
||||
success: function (model, response) {
|
||||
cacheThis.collection.fetch({
|
||||
success: function() {
|
||||
cacheThis.render();
|
||||
},
|
||||
reset: true
|
||||
});
|
||||
var confirm = new CMS.Views.Prompt.Warning({
|
||||
title: gettext('Are you sure you want to delete this update?'),
|
||||
message: gettext('This action cannot be undone.'),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext('OK'),
|
||||
click: function () {
|
||||
analytics.track('Deleted Course Update', {
|
||||
'course': course_location_analytics,
|
||||
'date': self.dateEntry(event).val()
|
||||
});
|
||||
self.modelDom(event).remove();
|
||||
var deleting = new CMS.Views.Notification.Mini({
|
||||
title: gettext('Deleting') + '…'
|
||||
});
|
||||
deleting.show();
|
||||
targetModel.destroy({
|
||||
success: function (model, response) {
|
||||
self.collection.fetch({
|
||||
success: function() {
|
||||
self.render();
|
||||
deleting.hide();
|
||||
},
|
||||
reset: true
|
||||
});
|
||||
}
|
||||
});
|
||||
confirm.hide();
|
||||
}
|
||||
},
|
||||
secondary: {
|
||||
text: gettext('Cancel'),
|
||||
click: function() {
|
||||
confirm.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
confirm.show();
|
||||
},
|
||||
|
||||
closeEditor: function(self, removePost) {
|
||||
var targetModel = self.collection.get(self.$currentPost.attr('name'));
|
||||
@@ -241,16 +264,11 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.template = _.template($("#course_info_handouts-tpl").text());
|
||||
var self = this;
|
||||
this.model.fetch({
|
||||
complete: function() {
|
||||
window.templateLoader.loadRemoteTemplate("course_info_handouts",
|
||||
"/static/client_templates/course_info_handouts.html",
|
||||
function (raw_template) {
|
||||
self.template = _.template(raw_template);
|
||||
self.render();
|
||||
}
|
||||
);
|
||||
self.render();
|
||||
},
|
||||
reset: true
|
||||
});
|
||||
@@ -293,7 +311,15 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({
|
||||
onSave: function(event) {
|
||||
this.model.set('data', this.$codeMirror.getValue());
|
||||
this.render();
|
||||
this.model.save({});
|
||||
var saving = new CMS.Views.Notification.Mini({
|
||||
title: gettext('Saving') + '…'
|
||||
});
|
||||
saving.show();
|
||||
this.model.save({}, {
|
||||
success: function() {
|
||||
saving.hide();
|
||||
}
|
||||
});
|
||||
this.$form.hide();
|
||||
this.closeEditor(this);
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ CMS.Views.Metadata.Editor = Backbone.View.extend({
|
||||
model.getType() === CMS.Models.Metadata.FLOAT_TYPE) {
|
||||
new CMS.Views.Metadata.Number(data);
|
||||
}
|
||||
else if(model.getType() === CMS.Models.Metadata.LIST_TYPE) {
|
||||
new CMS.Views.Metadata.List(data);
|
||||
}
|
||||
else {
|
||||
// Everything else is treated as GENERIC_TYPE, which uses String editor.
|
||||
new CMS.Views.Metadata.String(data);
|
||||
@@ -310,3 +313,59 @@ CMS.Views.Metadata.Option = CMS.Views.Metadata.AbstractEditor.extend({
|
||||
}).prop('selected', true);
|
||||
}
|
||||
});
|
||||
|
||||
CMS.Views.Metadata.List = CMS.Views.Metadata.AbstractEditor.extend({
|
||||
|
||||
events : {
|
||||
"click .setting-clear" : "clear",
|
||||
"keypress .setting-input" : "showClearButton",
|
||||
"change input" : "updateModel",
|
||||
"input input" : "enableAdd",
|
||||
"click .create-setting" : "addEntry",
|
||||
"click .remove-setting" : "removeEntry"
|
||||
},
|
||||
|
||||
templateName: "metadata-list-entry",
|
||||
|
||||
getValueFromEditor: function () {
|
||||
return _.map(
|
||||
this.$el.find('li input'),
|
||||
function (ele) { return ele.value.trim(); }
|
||||
).filter(_.identity);
|
||||
},
|
||||
|
||||
setValueInEditor: function (value) {
|
||||
var list = this.$el.find('ol');
|
||||
list.empty();
|
||||
_.each(value, function(ele, index) {
|
||||
var template = _.template(
|
||||
'<li class="list-settings-item">' +
|
||||
'<input type="text" class="input" value="<%= ele %>">' +
|
||||
'<a href="#" class="remove-action remove-setting" data-index="<%= index %>"><i class="icon-remove-sign"></i><span class="sr">Remove</span></a>' +
|
||||
'</li>'
|
||||
);
|
||||
list.append($(template({'ele': ele, 'index': index})));
|
||||
});
|
||||
},
|
||||
|
||||
addEntry: function(event) {
|
||||
event.preventDefault();
|
||||
// We don't call updateModel here since it's bound to the
|
||||
// change event
|
||||
var list = this.model.get('value') || [];
|
||||
this.setValueInEditor(list.concat(['']))
|
||||
this.$el.find('.create-setting').addClass('is-disabled');
|
||||
},
|
||||
|
||||
removeEntry: function(event) {
|
||||
event.preventDefault();
|
||||
var entry = $(event.currentTarget).siblings().val();
|
||||
this.setValueInEditor(_.without(this.model.get('value'), entry));
|
||||
this.updateModel();
|
||||
this.$el.find('.create-setting').removeClass('is-disabled');
|
||||
},
|
||||
|
||||
enableAdd: function() {
|
||||
this.$el.find('.create-setting').removeClass('is-disabled');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ function generateCheckHoverState(selectorsToOpen, selectorsToShove) {
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function removeHesitate(event, ui) {
|
||||
@@ -225,12 +225,19 @@ function _handleReorder(event, ui, parentIdField, childrenSelector) {
|
||||
ui.draggable.attr("style", "position:relative;"); // STYLE hack too
|
||||
children.push(ui.draggable.data('id'));
|
||||
}
|
||||
var saving = new CMS.Views.Notification.Mini({
|
||||
title: gettext('Saving') + '…'
|
||||
});
|
||||
saving.show();
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data:JSON.stringify({ 'id' : subsection_id, 'children' : children})
|
||||
data:JSON.stringify({ 'id' : subsection_id, 'children' : children}),
|
||||
success: function() {
|
||||
saving.hide();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -11,16 +11,9 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
|
||||
// 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();
|
||||
}
|
||||
);
|
||||
this.template = _.template($("#advanced_entry-tpl").text());
|
||||
this.listenTo(this.model, 'invalid', this.handleValidationError);
|
||||
this.render();
|
||||
},
|
||||
render: function() {
|
||||
// catch potential outside call before template loaded
|
||||
@@ -56,7 +49,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
|
||||
CodeMirror.fromTextArea(textarea, {
|
||||
mode: "application/json", lineNumbers: false, lineWrapping: false,
|
||||
onChange: function(instance, changeobj) {
|
||||
instance.save()
|
||||
instance.save();
|
||||
// this event's being called even when there's no change :-(
|
||||
if (instance.getValue() !== oldValue) {
|
||||
var message = gettext("Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.");
|
||||
@@ -105,8 +98,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
|
||||
// call validateKey on each to ensure proper format
|
||||
// check for dupes
|
||||
var self = this;
|
||||
this.model.save({},
|
||||
{
|
||||
this.model.save({}, {
|
||||
success : function() {
|
||||
self.render();
|
||||
var title = gettext("Your policy changes have been saved.");
|
||||
|
||||
@@ -368,42 +368,6 @@ p, ul, ol, dl {
|
||||
color: $gray-d3;
|
||||
}
|
||||
}
|
||||
|
||||
.introduction {
|
||||
@include box-sizing(border-box);
|
||||
@extend .t-copy-sub1;
|
||||
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 {
|
||||
@extend .t-copy-sub2;
|
||||
float: right;
|
||||
width: flex-grid(4,12);
|
||||
display: block;
|
||||
text-align: right;
|
||||
|
||||
.icon {
|
||||
@extend .t-action3;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-primary, .content-supplementary {
|
||||
@@ -482,6 +446,24 @@ p, ul, ol, dl {
|
||||
}
|
||||
}
|
||||
|
||||
// actions
|
||||
.list-actions {
|
||||
@extend .cont-no-list;
|
||||
|
||||
.action-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// navigation
|
||||
.nav-related, .nav-page {
|
||||
|
||||
|
||||
@@ -2,10 +2,39 @@
|
||||
// // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/)
|
||||
// ====================
|
||||
|
||||
// view - dashboard
|
||||
body.dashboard {
|
||||
|
||||
// elements - authorship controls
|
||||
.wrapper-authorshiprights {
|
||||
|
||||
.ui-toggle-control {
|
||||
// needed to override general a element transition properties - need to fix.
|
||||
transition-duration: 0.25s;
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.icon-remove-sign {
|
||||
// needed to override general a element transition properties - need to fix.
|
||||
transition-duration: 0.25s;
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// yes we have no boldness today - need to fix the resets
|
||||
body strong,
|
||||
body b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// known things to do (paint the fence, sand the floor, wax on/off)
|
||||
// ====================
|
||||
|
||||
|
||||
// known things to do (paint the fence, sand the floor, wax on/off):
|
||||
|
||||
// * centralize and move form styling into forms.scss - cms/static/sass/views/_textbooks.scss and cms/static/sass/views/_settings.scss
|
||||
// * move dialogue styles into cms/static/sass/elements/_modal.scss
|
||||
// * use the @include placeholder Bourbon mixin (http://bourbon.io/docs/#placeholder) for any placeholder styling
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
// xmodule
|
||||
@import 'xmodule/modules/css/module-styles.scss';
|
||||
@import 'xmodule/descriptors/css/module-styles.scss';
|
||||
@import 'elements/xmodules'; // styling for Studio-specific contexts
|
||||
|
||||
|
||||
@import 'shame'; // shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/)
|
||||
|
||||
@@ -93,6 +93,227 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
// ELEM: form wrapper
|
||||
.wrapper-create-element {
|
||||
height: 0;
|
||||
margin-bottom: $baseline;
|
||||
opacity: 0.0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
&.animate {
|
||||
@include transition(opacity $tmg-f1 ease-in-out 0s, height $tmg-f1 ease-in-out 0s);
|
||||
}
|
||||
|
||||
&.is-shown {
|
||||
height: auto; // define a specific height for the animating version of this UI to work properly
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ELEM: form
|
||||
// form styling for creating a new content item (course, user, textbook)
|
||||
form[class^="create-"] {
|
||||
@extend .ui-window;
|
||||
|
||||
.title {
|
||||
@extend .t-title4;
|
||||
font-weight: 600;
|
||||
padding: $baseline ($baseline*1.5) 0 ($baseline*1.5);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
padding: $baseline ($baseline*1.5);
|
||||
}
|
||||
|
||||
|
||||
.list-input {
|
||||
@extend .cont-no-list;
|
||||
|
||||
.field {
|
||||
margin: 0 0 ($baseline*0.75) 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.required {
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label:after {
|
||||
margin-left: ($baseline/4);
|
||||
content: "*";
|
||||
}
|
||||
}
|
||||
|
||||
label, input, textarea {
|
||||
display: block;
|
||||
}
|
||||
|
||||
label {
|
||||
@extend .t-copy-sub1;
|
||||
@include transition(color $tmg-f3 ease-in-out 0s);
|
||||
margin: 0 0 ($baseline/4) 0;
|
||||
|
||||
&.is-focused {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input, textarea {
|
||||
@extend .t-copy-base;
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: ($baseline/2);
|
||||
|
||||
&.long {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.short {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
/*@include placeholder {
|
||||
color: $gray-l3;
|
||||
}*/
|
||||
|
||||
&:focus {
|
||||
|
||||
+ .tip {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea.long {
|
||||
height: ($baseline*5);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
display: inline-block;
|
||||
margin-right: ($baseline/4);
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
& + label {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
@extend .t-copy-sub2;
|
||||
@include transition(color, 0.15s, ease-in-out);
|
||||
display: block;
|
||||
margin-top: ($baseline/4);
|
||||
color: $gray-l3;
|
||||
}
|
||||
|
||||
.tip-error {
|
||||
display: none;
|
||||
float: none;
|
||||
}
|
||||
|
||||
&.error {
|
||||
label {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
.tip-error {
|
||||
@extend .anim-fadeIn;
|
||||
display: block;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
input {
|
||||
border-color: $red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-inline {
|
||||
|
||||
input, textarea, select {
|
||||
width: 62%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tip-stacked {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
width: 35%;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&.error {
|
||||
.tip-error {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.field-group {
|
||||
@include clearfix();
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
width: 47%;
|
||||
border-bottom: none;
|
||||
margin: 0 ($baseline*0.75) 0 0;
|
||||
padding: ($baseline/4) 0 0 0;
|
||||
float: left;
|
||||
position: relative;
|
||||
|
||||
&:nth-child(odd) {
|
||||
float: left;
|
||||
}
|
||||
|
||||
&:nth-child(even) {
|
||||
float: right;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
box-shadow: inset 0 1px 2px $shadow;
|
||||
margin-top: ($baseline*0.75);
|
||||
border-top: 1px solid $gray-l1;
|
||||
padding: ($baseline*0.75) ($baseline*1.5);
|
||||
background: $gray-l6;
|
||||
|
||||
.action {
|
||||
@include transition(all $tmg-f2 linear 0s);
|
||||
display: inline-block;
|
||||
padding: ($baseline/5) $baseline;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@include blue-button;
|
||||
@extend .t-action2;
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
@include grey-button;
|
||||
@extend .t-action2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// forms - grandfathered
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// studio - elements - icons
|
||||
// studio - elements - icons & badges
|
||||
// ====================
|
||||
|
||||
.icon {
|
||||
@@ -14,3 +14,45 @@
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
|
||||
// ui - badges
|
||||
.wrapper-ui-badge {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: ($baseline*1.5);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-badge {
|
||||
@extend .t-title9;
|
||||
position: relative;
|
||||
border-bottom-right-radius: ($baseline/10);
|
||||
border-bottom-left-radius: ($baseline/10);
|
||||
padding: ($baseline/4) ($baseline/2) ($baseline/4) ($baseline/2);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
* [class^="icon-"] {
|
||||
margin-right: ($baseline/5);
|
||||
}
|
||||
|
||||
// OPTION: add this class for a visual hanging display
|
||||
&.is-hanging {
|
||||
@include box-sizing(border-box);
|
||||
@extend .ui-depth2;
|
||||
top: -($baseline/4);
|
||||
|
||||
&:after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -($baseline/4);
|
||||
display: block;
|
||||
height: 0;
|
||||
width: 0;
|
||||
border-bottom: ($baseline/4) solid $black-t3;
|
||||
border-right: ($baseline/4) solid transparent;
|
||||
content: "";
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,16 @@ nav {
|
||||
opacity: 0.0;
|
||||
pointer-events: none;
|
||||
width: ($baseline*8);
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
|
||||
|
||||
// dropped down state
|
||||
&.is-shown {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,46 @@
|
||||
// studio - elements - system feedback
|
||||
// ====================
|
||||
|
||||
// messages
|
||||
.message {
|
||||
@extend .t-copy-sub1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-status {
|
||||
display: none;
|
||||
@include border-top-radius(2px);
|
||||
@include box-sizing(border-box);
|
||||
border-bottom: 2px solid $yellow-d2;
|
||||
margin: 0 0 $baseline 0;
|
||||
padding: ($baseline/2) $baseline;
|
||||
font-weight: 500;
|
||||
background: $yellow-d1;
|
||||
color: $white;
|
||||
|
||||
[class^="icon-"] {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
@include font-size(16);
|
||||
display: inline-block;
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: $red-d3;
|
||||
background: $red-l1;
|
||||
}
|
||||
|
||||
&.is-shown {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// alerts, notifications, prompts, and status communication
|
||||
// ====================
|
||||
|
||||
|
||||
@@ -1,40 +1,213 @@
|
||||
// studio - elements - system help
|
||||
// ====================
|
||||
|
||||
// notices - in-context: to be used as notices to users within the context of a form/action
|
||||
.notice-incontext {
|
||||
@extend .ui-well;
|
||||
border-radius: ($baseline/10);
|
||||
// view introductions - common greeting/starting points for the UI
|
||||
.content .introduction {
|
||||
@include box-sizing(border-box);
|
||||
margin-bottom: $baseline;
|
||||
|
||||
.title {
|
||||
@extend .t-title7;
|
||||
margin-bottom: ($baseline/4);
|
||||
@extend .t-title4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.copy {
|
||||
@extend .t-copy-sub1;
|
||||
@include transition(opacity $tmg-f2 ease-in-out 0s);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
// CASE: has links alongside
|
||||
&.has-links {
|
||||
@include clearfix();
|
||||
|
||||
.copy {
|
||||
opacity: 1.0;
|
||||
float: left;
|
||||
width: flex-grid(8,12);
|
||||
margin-right: flex-gutter();
|
||||
}
|
||||
|
||||
.nav-introduction-supplementary {
|
||||
@extend .t-copy-sub2;
|
||||
float: right;
|
||||
width: flex-grid(4,12);
|
||||
display: block;
|
||||
text-align: right;
|
||||
|
||||
.icon {
|
||||
@extend .t-action3;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// particular warnings around a workflow for something
|
||||
// notices - in-context: to be used as notices to users within the context of a form/action
|
||||
.notice-incontext {
|
||||
@extend .ui-well;
|
||||
border-radius: ($baseline/10);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: $baseline;
|
||||
|
||||
.title {
|
||||
@extend .t-title6;
|
||||
margin-bottom: ($baseline/2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.copy {
|
||||
@extend .t-copy-sub1;
|
||||
@include transition(opacity $tmg-f2 ease-in-out 0s);
|
||||
opacity: 0.75;
|
||||
margin-bottom: $baseline;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.has-status {
|
||||
|
||||
.status-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: ($baseline/4);
|
||||
opacity: 0.40;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: notice has actions {
|
||||
&.has-actions {
|
||||
|
||||
.list-actions {
|
||||
margin-top: ($baseline*0.75);
|
||||
|
||||
.action-item {
|
||||
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@extend .btn-primary-blue;
|
||||
@extend .t-action3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// list of notices all in one
|
||||
&.list-notices {
|
||||
|
||||
.notice-item {
|
||||
margin-bottom: $baseline;
|
||||
border-bottom: 1px solid $gray-l3;
|
||||
padding-bottom: $baseline;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// particular notice - warnings around a workflow for something
|
||||
.notice-workflow {
|
||||
background: $yellow-l5;
|
||||
|
||||
.copy {
|
||||
.status-indicator {
|
||||
background: $yellow;
|
||||
}
|
||||
|
||||
title {
|
||||
color: $gray-d1;
|
||||
}
|
||||
|
||||
.copy {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
// particular notice - instructional
|
||||
.notice-instruction {
|
||||
background-color: $gray-l4;
|
||||
|
||||
.title {
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
.copy {
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
&.has-actions {
|
||||
|
||||
.list-actions {
|
||||
|
||||
.action-item {
|
||||
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@extend .btn-primary-blue;
|
||||
@extend .t-action3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// particular notice - create
|
||||
.notice-create {
|
||||
background-color: $gray-l4;
|
||||
|
||||
.title {
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
.copy {
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
&.has-actions {
|
||||
|
||||
.list-actions {
|
||||
|
||||
.action-item {
|
||||
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@extend .btn-primary-green;
|
||||
@extend .t-action3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// particular notice - confirmation
|
||||
.notice-confirmation {
|
||||
background-color: $green-l5;
|
||||
|
||||
.status-indicator {
|
||||
background: $green-s1;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
.copy {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
15
cms/static/sass/elements/_xmodules.scss
Normal file
15
cms/static/sass/elements/_xmodules.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
// studio - elements - xmodules
|
||||
// ====================
|
||||
|
||||
// Video Alpha
|
||||
.xmodule_VideoAlphaModule {
|
||||
|
||||
// display mode
|
||||
&.xmodule_display {
|
||||
|
||||
// full screen
|
||||
.video-controls .add-fullscreen {
|
||||
display: none !important; // nasty, but needed to override the bad specificity of the xmodule css selectors
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,44 +252,3 @@ body.signup, body.signin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// messages
|
||||
.message {
|
||||
@extend .t-copy-sub1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-status {
|
||||
display: none;
|
||||
@include border-top-radius(2px);
|
||||
@include box-sizing(border-box);
|
||||
border-bottom: 2px solid $yellow-d2;
|
||||
margin: 0 0 $baseline 0;
|
||||
padding: ($baseline/2) $baseline;
|
||||
font-weight: 500;
|
||||
background: $yellow-d1;
|
||||
color: $white;
|
||||
|
||||
[class^="icon-"] {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
@include font-size(16);
|
||||
display: inline-block;
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: shade($red, 50%);
|
||||
background: tint($red, 20%);
|
||||
}
|
||||
|
||||
&.is-shown {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user