Commit b6ea50d5 by Jason Bau

Merge branch 'release' into edx-west/release-candidate-try

Conflicts:
	common/djangoapps/external_auth/tests/test_shib.py
	common/djangoapps/external_auth/views.py
	common/djangoapps/student/views.py
	lms/envs/common.py
	lms/envs/test.py
parents 111fd9b8 21a14eff
...@@ -75,4 +75,6 @@ Frances Botsford <frances@edx.org> ...@@ -75,4 +75,6 @@ Frances Botsford <frances@edx.org>
Jonah Stanley <Jonah_Stanley@brown.edu> Jonah Stanley <Jonah_Stanley@brown.edu>
Slater Victoroff <slater.r.victoroff@gmail.com> Slater Victoroff <slater.r.victoroff@gmail.com>
Peter Fogg <peter.p.fogg@gmail.com> Peter Fogg <peter.p.fogg@gmail.com>
Renzo Lucioni <renzolucioni@gmail.com> Bethany LaPenta <lapentab@mit.edu>
\ No newline at end of file Renzo Lucioni <renzolucioni@gmail.com>
Felix Sun <felixsun@mit.edu>
...@@ -5,6 +5,43 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,43 @@ 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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Common: Add tests for documentation generation to test suite
Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems
LMS: Users are no longer auto-activated if they click "reset password"
This is now done when they click on the link in the reset password
email they receive (along with usual path through activation email).
LMS: Problem rescoring. Added options on the Grades tab of the
Instructor Dashboard to allow a particular student's submission for a
particular problem to be rescored. Provides an option to see a
history of background tasks for a given problem and student.
Blades: Small UX fix on capa multiple-choice problems. Make labels only
as wide as the text to reduce accidental choice selections.
Studio: Remove XML from the video component editor. All settings are
moved to be edited as metadata.
XModule: Only write out assets files if the contents have changed.
XModule: Don't delete generated xmodule asset files when compiling (for
instance, when XModule provides a coffeescript file, don't delete
the associated javascript)
Studio: For courses running on edx.org (marketing site), disable fields in
Course Settings that do not apply.
Common: Make asset watchers run as singletons (so they won't start if the
watcher is already running in another shell).
Common: Use coffee directly when watching for coffeescript file changes.
Common: Make rake provide better error messages if packages are missing.
Common: Repairs development documentation generation by sphinx.
LMS: Problem rescoring. Added options on the Grades tab of the LMS: Problem rescoring. Added options on the Grades tab of the
Instructor Dashboard to allow all students' submissions for a Instructor Dashboard to allow all students' submissions for a
particular problem to be rescored. Also supports resetting all particular problem to be rescored. Also supports resetting all
...@@ -12,6 +49,8 @@ students' number of attempts to zero. Provides a list of background ...@@ -12,6 +49,8 @@ students' number of attempts to zero. Provides a list of background
tasks that are currently running for the course, and an option to tasks that are currently running for the course, and an option to
see a history of background tasks for a given problem. see a history of background tasks for a given problem.
LMS: Fixed the preferences scope for storing data in xmodules.
LMS: Forums. Added handling for case where discussion module can get `None` as LMS: Forums. Added handling for case where discussion module can get `None` as
value of lms.start in `lms/djangoapps/django_comment_client/utils.py` value of lms.start in `lms/djangoapps/django_comment_client/utils.py`
...@@ -25,6 +64,8 @@ setting now run entirely outside the Python sandbox. ...@@ -25,6 +64,8 @@ setting now run entirely outside the Python sandbox.
Blades: Added tests for Video Alpha player. Blades: Added tests for Video Alpha player.
Common: Have the capa module handle unicode better (especially errors)
Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox. Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox.
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
......
...@@ -4,3 +4,4 @@ gem 'sass', '3.1.15' ...@@ -4,3 +4,4 @@ gem 'sass', '3.1.15'
gem 'bourbon', '~> 1.3.6' gem 'bourbon', '~> 1.3.6'
gem 'colorize', '~> 0.5.8' gem 'colorize', '~> 0.5.8'
gem 'launchy', '~> 2.1.2' gem 'launchy', '~> 2.1.2'
gem 'sys-proctable', '~> 0.9.3'
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.conf import settings
from xmodule.modulestore import Location from xmodule.modulestore import Location
...@@ -12,6 +13,9 @@ but this implementation should be data compatible with the LMS implementation ...@@ -12,6 +13,9 @@ but this implementation should be data compatible with the LMS implementation
INSTRUCTOR_ROLE_NAME = 'instructor' INSTRUCTOR_ROLE_NAME = 'instructor'
STAFF_ROLE_NAME = 'staff' STAFF_ROLE_NAME = 'staff'
# This is the group of people who have permission to create new courses on edge or edx.
COURSE_CREATOR_GROUP_NAME = "course_creator_group"
# we're just making a Django group for each location/role combo # we're just making a Django group for each location/role combo
# to do this we're just creating a Group name which is a formatted string # to do this we're just creating a Group name which is a formatted string
# of those two variables # of those two variables
...@@ -36,12 +40,10 @@ def get_users_in_course_group_by_role(location, role): ...@@ -36,12 +40,10 @@ def get_users_in_course_group_by_role(location, role):
return group.user_set.all() return group.user_set.all()
'''
Create all permission groups for a new course and subscribe the caller into those roles
'''
def create_all_course_groups(creator, location): def create_all_course_groups(creator, location):
"""
Create all permission groups for a new course and subscribe the caller into those roles
"""
create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME) create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME)
create_new_course_group(creator, location, STAFF_ROLE_NAME) create_new_course_group(creator, location, STAFF_ROLE_NAME)
...@@ -57,13 +59,11 @@ def create_new_course_group(creator, location, role): ...@@ -57,13 +59,11 @@ def create_new_course_group(creator, location, role):
return return
'''
This is to be called only by either a command line code path or through a app which has already
asserted permissions
'''
def _delete_course_group(location): def _delete_course_group(location):
"""
This is to be called only by either a command line code path or through a app which has already
asserted permissions
"""
# remove all memberships # remove all memberships
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all(): for user in instructors.user_set.all():
...@@ -75,13 +75,11 @@ def _delete_course_group(location): ...@@ -75,13 +75,11 @@ def _delete_course_group(location):
user.groups.remove(staff) user.groups.remove(staff)
user.save() user.save()
'''
This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action
'''
def _copy_course_group(source, dest): def _copy_course_group(source, dest):
"""
This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action
"""
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME))
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all(): for user in instructors.user_set.all():
...@@ -100,10 +98,34 @@ def add_user_to_course_group(caller, user, location, role): ...@@ -100,10 +98,34 @@ def add_user_to_course_group(caller, user, location, role):
if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME): if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME):
raise PermissionDenied raise PermissionDenied
if user.is_active and user.is_authenticated: group = Group.objects.get(name=get_course_groupname_for_role(location, role))
groupname = get_course_groupname_for_role(location, role) return _add_user_to_group(user, group)
group = Group.objects.get(name=groupname) def add_user_to_creator_group(caller, user):
"""
Adds the user to the group of course creators.
The caller must have staff access to perform this operation.
Note that on the edX site, we currently limit course creators to edX staff, and this
method is a no-op in that environment.
"""
if not caller.is_active or not caller.is_authenticated or not caller.is_staff:
raise PermissionDenied
(group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME)
if created:
group.save()
return _add_user_to_group(user, group)
def _add_user_to_group(user, group):
"""
This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action
"""
if user.is_active and user.is_authenticated:
user.groups.add(group) user.groups.add(group)
user.save() user.save()
return True return True
...@@ -129,11 +151,29 @@ def remove_user_from_course_group(caller, user, location, role): ...@@ -129,11 +151,29 @@ def remove_user_from_course_group(caller, user, location, role):
# see if the user is actually in that role, if not then we don't have to do anything # see if the user is actually in that role, if not then we don't have to do anything
if is_user_in_course_group_role(user, location, role): if is_user_in_course_group_role(user, location, role):
groupname = get_course_groupname_for_role(location, role) _remove_user_from_group(user, get_course_groupname_for_role(location, role))
def remove_user_from_creator_group(caller, user):
"""
Removes user from the course creator group.
The caller must have staff access to perform this operation.
"""
if not caller.is_active or not caller.is_authenticated or not caller.is_staff:
raise PermissionDenied
_remove_user_from_group(user, COURSE_CREATOR_GROUP_NAME)
group = Group.objects.get(name=groupname)
user.groups.remove(group) def _remove_user_from_group(user, group_name):
user.save() """
This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action
"""
group = Group.objects.get(name=group_name)
user.groups.remove(group)
user.save()
def is_user_in_course_group_role(user, location, role): def is_user_in_course_group_role(user, location, role):
...@@ -142,3 +182,26 @@ def is_user_in_course_group_role(user, location, role): ...@@ -142,3 +182,26 @@ def is_user_in_course_group_role(user, location, role):
return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0 return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0
return False return False
def is_user_in_creator_group(user):
"""
Returns true if the user has permissions to create a course.
Will always return True if user.is_staff is True.
Note that on the edX site, we currently limit course creators to edX staff. On
other sites, this method checks that the user is in the course creator group.
"""
if user.is_staff:
return True
# On edx, we only allow edX staff to create courses. This may be relaxed in the future.
if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False):
return False
# Feature flag for using the creator group setting. Will be removed once the feature is complete.
if settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False):
return user.groups.filter(name=COURSE_CREATOR_GROUP_NAME).count() > 0
return True
"""
Tests authz.py
"""
import mock
from django.test import TestCase
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from auth.authz import add_user_to_creator_group, remove_user_from_creator_group, is_user_in_creator_group,\
create_all_course_groups, add_user_to_course_group, STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME,\
is_user_in_course_group_role, remove_user_from_course_group
class CreatorGroupTest(TestCase):
"""
Tests for the course creator group.
"""
def setUp(self):
""" Test case setup """
self.user = User.objects.create_user('testuser', 'test+courses@edx.org', 'foo')
self.admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo')
self.admin.is_staff = True
def test_creator_group_not_enabled(self):
"""
Tests that is_user_in_creator_group always returns True if ENABLE_CREATOR_GROUP
and DISABLE_COURSE_CREATION are both not turned on.
"""
self.assertTrue(is_user_in_creator_group(self.user))
def test_creator_group_enabled_but_empty(self):
""" Tests creator group feature on, but group empty. """
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
self.assertFalse(is_user_in_creator_group(self.user))
# Make user staff. This will cause is_user_in_creator_group to return True.
self.user.is_staff = True
self.assertTrue(is_user_in_creator_group(self.user))
def test_creator_group_enabled_nonempty(self):
""" Tests creator group feature on, user added. """
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}):
self.assertTrue(add_user_to_creator_group(self.admin, self.user))
self.assertTrue(is_user_in_creator_group(self.user))
# check that a user who has not been added to the group still returns false
user_not_added = User.objects.create_user('testuser2', 'test+courses2@edx.org', 'foo2')
self.assertFalse(is_user_in_creator_group(user_not_added))
# remove first user from the group and verify that is_user_in_creator_group now returns false
remove_user_from_creator_group(self.admin, self.user)
self.assertFalse(is_user_in_creator_group(self.user))
def test_add_user_not_authenticated(self):
"""
Tests that adding to creator group fails if user is not authenticated
"""
self.user.is_authenticated = False
self.assertFalse(add_user_to_creator_group(self.admin, self.user))
def test_add_user_not_active(self):
"""
Tests that adding to creator group fails if user is not active
"""
self.user.is_active = False
self.assertFalse(add_user_to_creator_group(self.admin, self.user))
def test_course_creation_disabled(self):
""" Tests that the COURSE_CREATION_DISABLED flag overrides course creator group settings. """
with mock.patch.dict('django.conf.settings.MITX_FEATURES',
{'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP": True}):
# Add user to creator group.
self.assertTrue(add_user_to_creator_group(self.admin, self.user))
# DISABLE_COURSE_CREATION overrides (user is not marked as staff).
self.assertFalse(is_user_in_creator_group(self.user))
# Mark as staff. Now is_user_in_creator_group returns true.
self.user.is_staff = True
self.assertTrue(is_user_in_creator_group(self.user))
# Remove user from creator group. is_user_in_creator_group still returns true because is_staff=True
remove_user_from_creator_group(self.admin, self.user)
self.assertTrue(is_user_in_creator_group(self.user))
def test_add_user_to_group_requires_staff_access(self):
with self.assertRaises(PermissionDenied):
self.admin.is_staff = False
add_user_to_creator_group(self.admin, self.user)
with self.assertRaises(PermissionDenied):
add_user_to_creator_group(self.user, self.user)
def test_add_user_to_group_requires_active(self):
with self.assertRaises(PermissionDenied):
self.admin.is_active = False
add_user_to_creator_group(self.admin, self.user)
def test_add_user_to_group_requires_authenticated(self):
with self.assertRaises(PermissionDenied):
self.admin.is_authenticated = False
add_user_to_creator_group(self.admin, self.user)
def test_remove_user_from_group_requires_staff_access(self):
with self.assertRaises(PermissionDenied):
self.admin.is_staff = False
remove_user_from_creator_group(self.admin, self.user)
def test_remove_user_from_group_requires_active(self):
with self.assertRaises(PermissionDenied):
self.admin.is_active = False
remove_user_from_creator_group(self.admin, self.user)
def test_remove_user_from_group_requires_authenticated(self):
with self.assertRaises(PermissionDenied):
self.admin.is_authenticated = False
remove_user_from_creator_group(self.admin, self.user)
class CourseGroupTest(TestCase):
"""
Tests for instructor and staff groups for a particular course.
"""
def setUp(self):
""" Test case setup """
self.creator = User.objects.create_user('testcreator', 'testcreator+courses@edx.org', 'foo')
self.staff = User.objects.create_user('teststaff', 'teststaff+courses@edx.org', 'foo')
self.location = 'i4x', 'mitX', '101', 'course', 'test'
def test_add_user_to_course_group(self):
"""
Tests adding user to course group (happy path).
"""
# Create groups for a new course (and assign instructor role to the creator).
self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME))
create_all_course_groups(self.creator, self.location)
self.assertTrue(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME))
# Add another user to the staff role.
self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME))
self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME))
self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME))
def test_add_user_to_course_group_permission_denied(self):
"""
Verifies PermissionDenied if caller of add_user_to_course_group is not instructor role.
"""
create_all_course_groups(self.creator, self.location)
with self.assertRaises(PermissionDenied):
add_user_to_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME)
def test_remove_user_from_course_group(self):
"""
Tests removing user from course group (happy path).
"""
create_all_course_groups(self.creator, self.location)
self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME))
self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME))
remove_user_from_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)
self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME))
remove_user_from_course_group(self.creator, self.creator, self.location, INSTRUCTOR_ROLE_NAME)
self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME))
def test_remove_user_from_course_group_permission_denied(self):
"""
Verifies PermissionDenied if caller of remove_user_from_course_group is not instructor role.
"""
create_all_course_groups(self.creator, self.location)
with self.assertRaises(PermissionDenied):
remove_user_from_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME)
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
#pylint: disable=W0621 #pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_false, assert_equal, assert_regexp_matches from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true
from common import type_in_codemirror from common import type_in_codemirror
KEY_CSS = '.key input.policy-key' KEY_CSS = '.key input.policy-key'
...@@ -27,8 +27,16 @@ def i_am_on_advanced_course_settings(step): ...@@ -27,8 +27,16 @@ def i_am_on_advanced_course_settings(step):
@step(u'I press the "([^"]*)" notification button$') @step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name): def press_the_notification_button(step, name):
css = 'a.%s-button' % name.lower() css = 'a.action-%s' % name.lower()
world.css_click(css)
# Save was clicked if either the save notification bar is gone, or we have a error notification
# overlaying it (expected in the case of typing Object into display_name).
def save_clicked():
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
assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.')
@step(u'I edit the value of a policy key$') @step(u'I edit the value of a policy key$')
......
#pylint: disable=C0111 # pylint: disable=C0111
#pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_true from nose.tools import assert_true
from nose.tools import assert_equal
from auth.authz import get_user_by_email from auth.authz import get_user_by_email
...@@ -13,10 +12,15 @@ import time ...@@ -13,10 +12,15 @@ import time
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
_COURSE_NAME = 'Robot Super Course'
_COURSE_NUM = '999'
_COURSE_ORG = 'MITx'
########### STEP HELPERS ############## ########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$') @step('I (?:visit|access|open) the Studio homepage$')
def i_visit_the_studio_homepage(step): def i_visit_the_studio_homepage(_step):
# To make this go to port 8001, put # To make this go to port 8001, put
# LETTUCE_SERVER_PORT = 8001 # LETTUCE_SERVER_PORT = 8001
# in your settings.py file. # in your settings.py file.
...@@ -26,17 +30,17 @@ def i_visit_the_studio_homepage(step): ...@@ -26,17 +30,17 @@ def i_visit_the_studio_homepage(step):
@step('I am logged into Studio$') @step('I am logged into Studio$')
def i_am_logged_into_studio(step): def i_am_logged_into_studio(_step):
log_into_studio() log_into_studio()
@step('I confirm the alert$') @step('I confirm the alert$')
def i_confirm_with_ok(step): def i_confirm_with_ok(_step):
world.browser.get_alert().accept() world.browser.get_alert().accept()
@step(u'I press the "([^"]*)" delete icon$') @step(u'I press the "([^"]*)" delete icon$')
def i_press_the_category_delete_icon(step, category): def i_press_the_category_delete_icon(_step, category):
if category == 'section': if category == 'section':
css = 'a.delete-button.delete-section-button span.delete-icon' css = 'a.delete-button.delete-section-button span.delete-icon'
elif category == 'subsection': elif category == 'subsection':
...@@ -47,13 +51,14 @@ def i_press_the_category_delete_icon(step, category): ...@@ -47,13 +51,14 @@ def i_press_the_category_delete_icon(step, category):
@step('I have opened a new course in Studio$') @step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step): def i_have_opened_a_new_course(_step):
open_new_course() open_new_course()
####### HELPER FUNCTIONS ############## ####### HELPER FUNCTIONS ##############
def open_new_course(): def open_new_course():
world.clear_courses() world.clear_courses()
create_studio_user()
log_into_studio() log_into_studio()
create_a_course() create_a_course()
...@@ -75,9 +80,9 @@ def create_studio_user( ...@@ -75,9 +80,9 @@ def create_studio_user(
def fill_in_course_info( def fill_in_course_info(
name='Robot Super Course', name=_COURSE_NAME,
org='MITx', org=_COURSE_ORG,
num='101'): num=_COURSE_NUM):
world.css_fill('.new-course-name', name) world.css_fill('.new-course-name', name)
world.css_fill('.new-course-org', org) world.css_fill('.new-course-org', org)
world.css_fill('.new-course-number', num) world.css_fill('.new-course-number', num)
...@@ -86,10 +91,7 @@ def fill_in_course_info( ...@@ -86,10 +91,7 @@ def fill_in_course_info(
def log_into_studio( def log_into_studio(
uname='robot', uname='robot',
email='robot+studio@edx.org', email='robot+studio@edx.org',
password='test', password='test'):
is_staff=False):
create_studio_user(uname=uname, email=email, is_staff=is_staff)
world.browser.cookies.delete() world.browser.cookies.delete()
world.visit('/') world.visit('/')
...@@ -107,14 +109,14 @@ def log_into_studio( ...@@ -107,14 +109,14 @@ def log_into_studio(
def create_a_course(): def create_a_course():
c = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') world.CourseFactory.create(org=_COURSE_ORG, course=_COURSE_NUM, display_name=_COURSE_NAME)
# Add the user to the instructor group of the course # Add the user to the instructor group of the course
# so they will have the permissions to see it in studio # so they will have the permissions to see it in studio
g = world.GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course') course = world.GroupFactory.create(name='instructor_MITx/{course_num}/{course_name}'.format(course_num=_COURSE_NUM, course_name=_COURSE_NAME.replace(" ", "_")))
u = get_user_by_email('robot+studio@edx.org') user = get_user_by_email('robot+studio@edx.org')
u.groups.add(g) user.groups.add(course)
u.save() user.save()
world.browser.reload() world.browser.reload()
course_link_css = 'span.class-name' course_link_css = 'span.class-name'
...@@ -147,6 +149,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): ...@@ -147,6 +149,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
world.css_fill(date_css, desired_date) world.css_fill(date_css, desired_date)
# hit TAB to get to the time field # hit TAB to get to the time field
e = world.css_find(date_css).first e = world.css_find(date_css).first
# pylint: disable=W0212
e._element.send_keys(Keys.TAB) e._element.send_keys(Keys.TAB)
world.css_fill(time_css, desired_time) world.css_fill(time_css, desired_time)
e = world.css_find(time_css).first e = world.css_find(time_css).first
...@@ -171,6 +174,16 @@ def open_new_unit(step): ...@@ -171,6 +174,16 @@ def open_new_unit(step):
world.css_click('a.new-unit-item') world.css_click('a.new-unit-item')
@step('when I view the video it (.*) show the captions')
def shows_captions(step, show_captions):
# Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions')
if show_captions == 'does not':
assert world.css_find('.video')[0].has_class('closed')
else:
assert world.is_css_not_present('.video.closed')
def type_in_codemirror(index, text): def type_in_codemirror(index, text):
world.css_click(".CodeMirror", index=index) world.css_click(".CodeMirror", index=index)
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
......
Feature: Course Team
As a course author, I want to be able to add others to my team
Scenario: Users 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
When I add "alice" to the course team
And "alice" logs in
Then she does see the course on her page
Scenario: Added users 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
Then he cannot delete users
And he cannot add users
Scenario: Users 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
When I add "carol" to the course team
And I delete "carol" from the 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
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
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from common import create_studio_user, log_into_studio, _COURSE_NAME
PASSWORD = 'test'
EMAIL_EXTENSION = '@edx.org'
@step(u'I am viewing the course team settings')
def view_grading_settings(_step):
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'I add "([^"]*)" to the course team')
def add_other_user(_step, name):
new_user_css = 'a.new-user-button'
world.css_click(new_user_css)
email_css = 'input.email-input'
f = world.css_find(email_css)
f._element.send_keys(name, EMAIL_EXTENSION)
confirm_css = '#add_user'
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)
world.css_click(to_delete_css)
@step(u'"([^"]*)" logs in$')
def other_user_login(_step, name):
log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION)
@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'
all_courses = world.css_find(class_css)
all_names = [item.html for item in all_courses]
if doesnt_see_course:
assert not _COURSE_NAME in all_names
else:
assert _COURSE_NAME in all_names
@step(u's?he cannot delete users')
def cannot_delete(_step):
to_delete_css = 'a.remove-user'
assert world.is_css_not_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)
Feature: Course updates
As a course author, I want to be able to provide updates to my students
Scenario: Users can add updates
Given I have opened a new course in Studio
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"
Scenario: Users can edit updates
Given I have opened a new course in Studio
And I go to the course updates page
When I add a new update with the text "Hello"
And I modify the text to "Goodbye"
Then I should see the update "Goodbye"
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
Then I should not see the update "Hello"
Scenario: Users can edit update dates
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 edit the date to "June 1, 2013"
Then I should see the date "June 1, 2013"
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"
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from selenium.webdriver.common.keys import Keys
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'
world.css_click(menu_css)
world.css_click(updates_css)
@step(u'I add a new update with the text "([^"]*)"$')
def add_update(_step, text):
update_css = 'a.new-update-button'
world.css_click(update_css)
change_text(text)
@step(u'I should( not)? see the update "([^"]*)"$')
def check_update(_step, doesnt_see_update, text):
update_css = 'div.update-contents'
update = world.css_find(update_css)
if doesnt_see_update:
assert len(update) == 0 or not text in update.html
else:
assert text in update.html
@step(u'I modify the text to "([^"]*)"$')
def modify_update(_step, text):
button_css = 'div.post-preview a.edit-button'
world.css_click(button_css)
change_text(text)
@step(u'I delete the update$')
def click_button(_step):
button_css = 'div.post-preview a.delete-button'
world.css_click(button_css)
@step(u'I edit the date to "([^"]*)"$')
def change_date(_step, new_date):
button_css = 'div.post-preview a.edit-button'
world.css_click(button_css)
date_css = 'input.date'
date = world.css_find(date_css)
for i in range(len(date.value)):
date._element.send_keys(Keys.END, Keys.BACK_SPACE)
date._element.send_keys(new_date)
save_css = 'a.save-button'
world.css_click(save_css)
@step(u'I should see the date "([^"]*)"$')
def check_date(_step, date):
date_css = 'span.date-display'
date_html = world.css_find(date_css)
assert date == date_html.html
@step(u'I modify the handout to "([^"]*)"$')
def edit_handouts(_step, text):
edit_css = 'div.course-handouts > a.edit-button'
world.css_click(edit_css)
change_text(text)
@step(u'I see the handout "([^"]*)"$')
def check_handout(_step, handout):
handout_css = 'div.handouts-content'
handouts = world.css_find(handout_css)
assert handout in handouts.html
def change_text(text):
type_in_codemirror(0, text)
save_css = 'a.save-button'
world.css_click(save_css)
...@@ -10,6 +10,7 @@ from common import * ...@@ -10,6 +10,7 @@ from common import *
@step('There are no courses$') @step('There are no courses$')
def no_courses(step): def no_courses(step):
world.clear_courses() world.clear_courses()
create_studio_user()
@step('I click the New Course button$') @step('I click the New Course button$')
......
Feature: Course Grading
As a course author, I want to be able to configure how my course is graded
Scenario: Users can add grading ranges
Given I have opened a new course in Studio
And I am viewing the grading settings
When I add "1" new grade
Then I see I now have "3" grades
Scenario: Users can only have up to 5 grading ranges
Given I have opened a new course in Studio
And I am viewing the grading settings
When I add "6" new grades
Then I see I now have "5" grades
#Cannot reliably make the delete button appear so using javascript instead
Scenario: Users can delete grading ranges
Given I have opened a new course in Studio
And I am viewing the grading settings
When I add "1" new grade
And I delete a grade
Then I see I now have "2" grades
Scenario: Users can move grading ranges
Given I have opened a new course in Studio
And I am viewing the grading settings
When I move a grading section
Then I see that the grade range has changed
Scenario: Users can modify Assignment types
Given I have opened a new course in Studio
And I have populated the course
And I am viewing the grading settings
When I change assignment type "Homework" to "New Type"
And I go back to the main course page
Then I do see the assignment name "New Type"
And I do not see the assignment name "Homework"
Scenario: Users can delete Assignment types
Given I have opened a new course in Studio
And I have populated the course
And I am viewing the grading settings
When I delete the assignment type "Homework"
And I go back to the main course page
Then I do not see the assignment name "Homework"
Scenario: Users can add Assignment types
Given I have opened a new course in Studio
And I have populated the course
And I am viewing the grading settings
When I add a new assignment type "New Type"
And I go back to the main course page
Then I do see the assignment name "New Type"
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from common import *
@step(u'I am viewing the grading settings')
def view_grading_settings(step):
world.click_course_settings()
link_css = 'li.nav-course-settings-grading a'
world.css_click(link_css)
@step(u'I add "([^"]*)" new grade')
def add_grade(step, many):
grade_css = '.new-grade-button'
for i in range(int(many)):
world.css_click(grade_css)
@step(u'I delete a grade')
def delete_grade(step):
#grade_css = 'li.grade-specific-bar > a.remove-button'
#range_css = '.grade-specific-bar'
#world.css_find(range_css)[1].mouseover()
#world.css_click(grade_css)
world.browser.execute_script('document.getElementsByClassName("remove-button")[0].click()')
@step(u'I see I now have "([^"]*)" grades$')
def view_grade_slider(step, how_many):
grade_slider_css = '.grade-specific-bar'
all_grades = world.css_find(grade_slider_css)
assert len(all_grades) == int(how_many)
@step(u'I move a grading section')
def move_grade_slider(step):
moveable_css = '.ui-resizable-e'
f = world.css_find(moveable_css).first
f.action_chains.drag_and_drop_by_offset(f._element, 100, 0).perform()
@step(u'I see that the grade range has changed')
def confirm_change(step):
range_css = '.range'
all_ranges = world.css_find(range_css)
for i in range(len(all_ranges)):
assert all_ranges[i].html != '0-50'
@step(u'I change assignment type "([^"]*)" to "([^"]*)"$')
def change_assignment_name(step, old_name, new_name):
name_id = '#course-grading-assignment-name'
index = get_type_index(old_name)
f = world.css_find(name_id)[index]
assert index != -1
for count in range(len(old_name)):
f._element.send_keys(Keys.END, Keys.BACK_SPACE)
f._element.send_keys(new_name)
@step(u'I go back to the main course page')
def main_course_page(step):
main_page_link_css = 'a[href="/MITx/999/course/Robot_Super_Course"]'
world.css_click(main_page_link_css)
@step(u'I do( not)? see the assignment name "([^"]*)"$')
def see_assignment_name(step, do_not, name):
assignment_menu_css = 'ul.menu > li > a'
assignment_menu = world.css_find(assignment_menu_css)
allnames = [item.html for item in assignment_menu]
if do_not:
assert not name in allnames
else:
assert name in allnames
@step(u'I delete the assignment type "([^"]*)"$')
def delete_assignment_type(step, to_delete):
delete_css = '.remove-grading-data'
world.css_click(delete_css, index=get_type_index(to_delete))
@step(u'I add a new assignment type "([^"]*)"$')
def add_assignment_type(step, new_name):
add_button_css = '.add-grading-data'
world.css_click(add_button_css)
name_id = '#course-grading-assignment-name'
f = world.css_find(name_id)[4]
f._element.send_keys(new_name)
@step(u'I have populated the course')
def populate_course(step):
step.given('I have added a new section')
step.given('I have added a new subsection')
def get_type_index(name):
name_id = '#course-grading-assignment-name'
f = world.css_find(name_id)
for i in range(len(f)):
if f[i].value == name:
return i
return -1
#pylint: disable=C0111 # pylint: disable=C0111
#pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
...@@ -8,7 +8,7 @@ from nose.tools import assert_equal ...@@ -8,7 +8,7 @@ from nose.tools import assert_equal
############### ACTIONS #################### ############### ACTIONS ####################
@step('I click the new section link$') @step('I click the New Section link$')
def i_click_new_section_link(_step): def i_click_new_section_link(_step):
link_css = 'a.new-courseware-section-button' link_css = 'a.new-courseware-section-button'
world.css_click(link_css) world.css_click(link_css)
......
Feature: Static Pages
As a course author, I want to be able to add static pages
Scenario: Users can add static pages
Given I have opened a new course in Studio
And I go to the static pages page
When I add a new page
Then I should see a "Empty" static page
Scenario: Users can delete static pages
Given I have opened a new course in Studio
And I go to the static pages page
And I add a new page
When I will confirm all alerts
And I "delete" the "Empty" page
Then I should not see a "Empty" static page
Scenario: Users can edit static pages
Given I have opened a new course in Studio
And I go to the static pages page
And I add a new page
When I "edit" the "Empty" page
And I change the name to "New"
Then I should see a "New" static page
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
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'
world.css_find(menu_css).click()
world.css_find(static_css).click()
@step(u'I add a new page')
def add_page(_step):
button_css = 'a.new-button'
world.css_find(button_css).click()
@step(u'I should( not)? see a "([^"]*)" static page$')
def see_page(_step, doesnt, page):
index = get_index(page)
if doesnt:
assert index == -1
else:
assert index != -1
@step(u'I "([^"]*)" the "([^"]*)" page$')
def click_edit_delete(_step, edit_delete, page):
button_css = 'a.%s-button' % edit_delete
index = get_index(page)
assert index != -1
world.css_find(button_css)[index].click()
@step(u'I change the name to "([^"]*)"$')
def change_name(_step, new_name):
settings_css = '#settings-mode'
world.css_find(settings_css).click()
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)
save_button = 'a.save-button'
world.css_find(save_button).click()
def get_index(name):
page_name_css = 'section[data-type="HTMLModule"]'
all_pages = world.css_find(page_name_css)
for i in range(len(all_pages)):
if all_pages[i].html == '\n {name}\n'.format(name=name):
return i
return -1
#pylint: disable=C0111 # pylint: disable=C0111
#pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
...@@ -50,7 +50,8 @@ def have_a_course_with_two_sections(step): ...@@ -50,7 +50,8 @@ def have_a_course_with_two_sections(step):
@step(u'I navigate to the course overview page$') @step(u'I navigate to the course overview page$')
def navigate_to_the_course_overview_page(step): def navigate_to_the_course_overview_page(step):
log_into_studio(is_staff=True) create_studio_user(is_staff=True)
log_into_studio()
course_locator = '.class-name' course_locator = '.class-name'
world.css_click(course_locator) world.css_click(course_locator)
......
Feature: Upload Files
As a course author, I want to be able to upload files for my students
Scenario: Users can upload files
Given I have opened a new course in Studio
And I go to the files and uploads page
When I upload the file "test"
Then I should see the file "test" was uploaded
And The url for the file "test" is valid
Scenario: Users can update files
Given I have opened a new course in studio
And I go to the files and uploads page
When I upload the file "test"
And I upload the file "test"
Then I should see only one "test"
Scenario: Users can delete uploaded files
Given I have opened a new course in studio
And I go to the files and uploads page
When I upload the file "test"
And I delete the file "test"
Then I should not see the file "test" was uploaded
Scenario: Users can download files
Given I have opened a new course in studio
And I go to the files and uploads page
When I upload the file "test"
Then I can download the correct "test" file
Scenario: Users can download updated files
Given I have opened a new course in studio
And I go to the files and uploads page
When I upload the file "test"
And I modify "test"
And I reload the page
And I upload the file "test"
Then I can download the correct "test" file
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from django.conf import settings
import requests
import string
import random
import os
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
HTTP_PREFIX = "http://localhost:8001"
@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'
world.css_find(menu_css).click()
world.css_find(uploads_css).click()
@step(u'I upload the file "([^"]*)"$')
def upload_file(_step, file_name):
upload_css = 'a.upload-button'
world.css_find(upload_css).click()
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))
close_css = 'a.close-button'
world.css_find(close_css).click()
@step(u'I should( not)? see the file "([^"]*)" was uploaded$')
def check_upload(_step, do_not_see_file, file_name):
index = get_index(file_name)
if do_not_see_file:
assert index == -1
else:
assert index != -1
@step(u'The url for the file "([^"]*)" is valid$')
def check_url(_step, file_name):
r = get_file(file_name)
assert r.status_code == 200
@step(u'I delete the file "([^"]*)"$')
def delete_file(_step, file_name):
index = get_index(file_name)
assert index != -1
delete_css = "a.remove-asset-button"
world.css_click(delete_css, index=index)
prompt_confirm_css = 'li.nav-item > a.action-primary'
world.css_click(prompt_confirm_css)
@step(u'I should see only one "([^"]*)"$')
def no_duplicate(_step, file_name):
names_css = 'td.name-col > a.filename'
all_names = world.css_find(names_css)
only_one = False
for i in range(len(all_names)):
if file_name == all_names[i].html:
only_one = not only_one
assert only_one
@step(u'I can download the correct "([^"]*)" file$')
def check_download(_step, file_name):
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
with open(os.path.abspath(path), 'r') as cur_file:
cur_text = cur_file.read()
r = get_file(file_name)
downloaded_text = r.text
assert cur_text == downloaded_text
@step(u'I modify "([^"]*)"$')
def modify_upload(_step, file_name):
new_text = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(10))
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
with open(os.path.abspath(path), 'w') as cur_file:
cur_file.write(new_text)
def get_index(file_name):
names_css = 'td.name-col > a.filename'
all_names = world.css_find(names_css)
for i in range(len(all_names)):
if file_name == all_names[i].html:
return i
return -1
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)
...@@ -4,10 +4,20 @@ Feature: Video Component Editor ...@@ -4,10 +4,20 @@ Feature: Video Component Editor
Scenario: User can view metadata Scenario: User can view metadata
Given I have created a Video component Given I have created a Video component
And I edit and select Settings And I edit and select Settings
Then I see only the Video display name setting Then I see the correct settings and default values
Scenario: User can modify display name Scenario: User can modify display name
Given I have created a Video component Given I have created a Video component
And I edit and select Settings And I edit and select Settings
Then I can modify the display name Then I can modify the display name
And my display name change is persisted on save And my display name change is persisted on save
Scenario: Captions are hidden when "show captions" is false
Given I have created a Video component
And I have set "show captions" to False
Then when I view the video it does not show the captions
Scenario: Captions are shown when "show captions" is true
Given I have created a Video component
And I have set "show captions" to True
Then when I view the video it does show the captions
...@@ -4,6 +4,20 @@ ...@@ -4,6 +4,20 @@
from lettuce import world, step from lettuce import world, step
@step('I see only the video display name setting$') @step('I see the correct settings and default values$')
def i_see_only_the_video_display_name(step): def i_see_the_correct_settings_and_values(step):
world.verify_all_setting_entries([['Display Name', "default", True]]) world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False],
['Display Name', 'default', True],
['Download Track', '', False],
['Download Video', '', False],
['Show Captions', 'True', False],
['Speed: .75x', '', False],
['Speed: 1.25x', '', False],
['Speed: 1.5x', '', False]])
@step('I have set "show captions" to (.*)')
def set_show_captions(step, setting):
world.css_click('a.edit-button')
world.browser.select('Show Captions', setting)
world.css_click('a.save-button')
...@@ -9,7 +9,16 @@ Feature: Video Component ...@@ -9,7 +9,16 @@ Feature: Video Component
Given I have clicked the new unit button Given I have clicked the new unit button
Then creating a video takes a single click Then creating a video takes a single click
Scenario: Captions are shown correctly Scenario: Captions are hidden correctly
Given I have created a Video component Given I have created a Video component
And I have hidden captions And I have hidden captions
Then when I view the video it does not show the captions Then when I view the video it does not show the captions
Scenario: Captions are shown correctly
Given I have created a Video component
Then when I view the video it does show the captions
Scenario: Captions are toggled correctly
Given I have created a Video component
And I have toggled captions
Then when I view the video it does show the captions
...@@ -6,23 +6,28 @@ from lettuce import world, step ...@@ -6,23 +6,28 @@ from lettuce import world, step
@step('when I view the video it does not have autoplay enabled') @step('when I view the video it does not have autoplay enabled')
def does_not_autoplay(step): def does_not_autoplay(_step):
assert world.css_find('.video')[0]['data-autoplay'] == 'False' assert world.css_find('.video')[0]['data-autoplay'] == 'False'
assert world.css_find('.video_control')[0].has_class('play') assert world.css_find('.video_control')[0].has_class('play')
@step('creating a video takes a single click') @step('creating a video takes a single click')
def video_takes_a_single_click(step): def video_takes_a_single_click(_step):
assert(not world.is_css_present('.xmodule_VideoModule')) assert(not world.is_css_present('.xmodule_VideoModule'))
world.css_click("a[data-location='i4x://edx/templates/video/default']") world.css_click("a[data-location='i4x://edx/templates/video/default']")
assert(world.is_css_present('.xmodule_VideoModule')) assert(world.is_css_present('.xmodule_VideoModule'))
@step('I have hidden captions') @step('I have (hidden|toggled) captions')
def set_show_captions_false(step): def hide_or_show_captions(step, shown):
world.css_click('a.hide-subtitles') button_css = 'a.hide-subtitles'
if shown == 'hidden':
world.css_click(button_css)
@step('when I view the video it does not show the captions') if shown == 'toggled':
def does_not_show_captions(step): world.css_click(button_css)
assert world.css_find('.video')[0].has_class('closed') # When we click the first time, a tooltip shows up. We want to
# 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()
world.css_click(button_css)
...@@ -39,10 +39,7 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links= ...@@ -39,10 +39,7 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
def set_module_info(store, location, post_data): def set_module_info(store, location, post_data):
module = None module = None
try: try:
if location.revision is None: module = store.get_item(location)
module = store.get_item(location)
else:
module = store.get_item(location)
except: except:
pass pass
......
...@@ -19,7 +19,6 @@ class ChecklistTestCase(CourseTestCase): ...@@ -19,7 +19,6 @@ class ChecklistTestCase(CourseTestCase):
modulestore = get_modulestore(self.course.location) modulestore = get_modulestore(self.course.location)
return modulestore.get_item(self.course.location).checklists return modulestore.get_item(self.course.location).checklists
def compare_checklists(self, persisted, request): def compare_checklists(self, persisted, request):
""" """
Handles url expansion as possible difference and descends into guts Handles url expansion as possible difference and descends into guts
......
"""
Tests for Studio Course Settings.
"""
import datetime import datetime
import json import json
import copy import copy
import mock
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test.client import Client from django.test.client import Client
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.timezone import UTC from django.utils.timezone import UTC
from django.test.utils import override_settings
from xmodule.modulestore import Location from xmodule.modulestore import Location
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder) from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
...@@ -21,6 +26,9 @@ from xmodule.fields import Date ...@@ -21,6 +26,9 @@ from xmodule.fields import Date
class CourseTestCase(ModuleStoreTestCase): class CourseTestCase(ModuleStoreTestCase):
"""
Base class for test classes below.
"""
def setUp(self): def setUp(self):
""" """
These tests need a user in the DB so that the django Test Client These tests need a user in the DB so that the django Test Client
...@@ -51,6 +59,9 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -51,6 +59,9 @@ class CourseTestCase(ModuleStoreTestCase):
class CourseDetailsTestCase(CourseTestCase): class CourseDetailsTestCase(CourseTestCase):
"""
Tests the first course settings page (course dates, overview, etc.).
"""
def test_virgin_fetch(self): def test_virgin_fetch(self):
details = CourseDetails.fetch(self.course_location) details = CourseDetails.fetch(self.course_location)
self.assertEqual(details.course_location, self.course_location, "Location not copied into") self.assertEqual(details.course_location, self.course_location, "Location not copied into")
...@@ -81,9 +92,9 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -81,9 +92,9 @@ class CourseDetailsTestCase(CourseTestCase):
Test the encoder out of its original constrained purpose to see if it functions for general use Test the encoder out of its original constrained purpose to see if it functions for general use
""" """
details = {'location': Location(['tag', 'org', 'course', 'category', 'name']), details = {'location': Location(['tag', 'org', 'course', 'category', 'name']),
'number': 1, 'number': 1,
'string': 'string', 'string': 'string',
'datetime': datetime.datetime.now(UTC())} 'datetime': datetime.datetime.now(UTC())}
jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
jsondetails = json.loads(jsondetails) jsondetails = json.loads(jsondetails)
...@@ -118,8 +129,60 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -118,8 +129,60 @@ class CourseDetailsTestCase(CourseTestCase):
jsondetails.effort, "After set effort" jsondetails.effort, "After set effort"
) )
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self):
settings_details_url = reverse(
'settings_details',
kwargs={
'org': self.course_location.org,
'name': self.course_location.name,
'course': self.course_location.course
}
)
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
response = self.client.get(settings_details_url)
self.assertContains(response, "Course Summary Page")
self.assertContains(response, "course summary page will not be viewable")
self.assertContains(response, "Course Start Date")
self.assertContains(response, "Course End Date")
self.assertNotContains(response, "Enrollment Start Date")
self.assertNotContains(response, "Enrollment End Date")
self.assertContains(response, "not the dates shown on your course summary page")
self.assertNotContains(response, "Introducing Your Course")
self.assertNotContains(response, "Requirements")
def test_regular_site_fetch(self):
settings_details_url = reverse(
'settings_details',
kwargs={
'org': self.course_location.org,
'name': self.course_location.name,
'course': self.course_location.course
}
)
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
response = self.client.get(settings_details_url)
self.assertContains(response, "Course Summary Page")
self.assertNotContains(response, "course summary page will not be viewable")
self.assertContains(response, "Course Start Date")
self.assertContains(response, "Course End Date")
self.assertContains(response, "Enrollment Start Date")
self.assertContains(response, "Enrollment End Date")
self.assertNotContains(response, "not the dates shown on your course summary page")
self.assertContains(response, "Introducing Your Course")
self.assertContains(response, "Requirements")
class CourseDetailsViewTest(CourseTestCase): class CourseDetailsViewTest(CourseTestCase):
"""
Tests for modifying content on the first course settings page (course dates, overview, etc.).
"""
def alter_field(self, url, details, field, val): def alter_field(self, url, details, field, val):
setattr(details, field, val) setattr(details, field, val)
# Need to partially serialize payload b/c the mock doesn't handle it correctly # Need to partially serialize payload b/c the mock doesn't handle it correctly
...@@ -181,6 +244,9 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -181,6 +244,9 @@ class CourseDetailsViewTest(CourseTestCase):
class CourseGradingTest(CourseTestCase): class CourseGradingTest(CourseTestCase):
"""
Tests for the course settings grading page.
"""
def test_initial_grader(self): def test_initial_grader(self):
descriptor = get_modulestore(self.course_location).get_item(self.course_location) descriptor = get_modulestore(self.course_location).get_item(self.course_location)
test_grader = CourseGradingModel(descriptor) test_grader = CourseGradingModel(descriptor)
...@@ -256,6 +322,9 @@ class CourseGradingTest(CourseTestCase): ...@@ -256,6 +322,9 @@ class CourseGradingTest(CourseTestCase):
class CourseMetadataEditingTest(CourseTestCase): class CourseMetadataEditingTest(CourseTestCase):
"""
Tests for CourseMetadata.
"""
def setUp(self): def setUp(self):
CourseTestCase.setUp(self) CourseTestCase.setUp(self)
# add in the full class too # add in the full class too
......
from contentstore.utils import get_modulestore, get_url_reverse
from contentstore.tests.test_course_settings import CourseTestCase from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
......
...@@ -224,14 +224,14 @@ def add_extra_panel_tab(tab_type, course): ...@@ -224,14 +224,14 @@ def add_extra_panel_tab(tab_type, course):
@param course: A course object from the modulestore. @param course: A course object from the modulestore.
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course. @return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
""" """
#Copy course tabs # Copy course tabs
course_tabs = copy.copy(course.tabs) course_tabs = copy.copy(course.tabs)
changed = False changed = False
#Check to see if open ended panel is defined in the course # Check to see if open ended panel is defined in the course
tab_panel = EXTRA_TAB_PANELS.get(tab_type) tab_panel = EXTRA_TAB_PANELS.get(tab_type)
if tab_panel not in course_tabs: if tab_panel not in course_tabs:
#Add panel to the tabs if it is not defined # Add panel to the tabs if it is not defined
course_tabs.append(tab_panel) course_tabs.append(tab_panel)
changed = True changed = True
return changed, course_tabs return changed, course_tabs
...@@ -244,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course): ...@@ -244,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course):
@param course: A course object from the modulestore. @param course: A course object from the modulestore.
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course. @return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
""" """
#Copy course tabs # Copy course tabs
course_tabs = copy.copy(course.tabs) course_tabs = copy.copy(course.tabs)
changed = False changed = False
#Check to see if open ended panel is defined in the course # Check to see if open ended panel is defined in the course
tab_panel = EXTRA_TAB_PANELS.get(tab_type) tab_panel = EXTRA_TAB_PANELS.get(tab_type)
if tab_panel in course_tabs: if tab_panel in course_tabs:
#Add panel to the tabs if it is not defined # Add panel to the tabs if it is not defined
course_tabs = [ct for ct in course_tabs if ct != tab_panel] course_tabs = [ct for ct in course_tabs if ct != tab_panel]
changed = True changed = True
return changed, course_tabs return changed, course_tabs
...@@ -12,8 +12,8 @@ from django.core.urlresolvers import reverse ...@@ -12,8 +12,8 @@ from django.core.urlresolvers import reverse
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, \
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError InvalidLocationError
from xmodule.modulestore import Location from xmodule.modulestore import Location
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
...@@ -21,7 +21,7 @@ from contentstore.utils import get_lms_link_for_item, add_extra_panel_tab, remov ...@@ -21,7 +21,7 @@ from contentstore.utils import get_lms_link_for_item, add_extra_panel_tab, remov
from models.settings.course_details import CourseDetails, CourseSettingsEncoder from models.settings.course_details import CourseDetails, CourseSettingsEncoder
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
from models.settings.course_metadata import CourseMetadata from models.settings.course_metadata import CourseMetadata
from auth.authz import create_all_course_groups from auth.authz import create_all_course_groups, is_user_in_creator_group
from util.json_request import expect_json from util.json_request import expect_json
from .access import has_access, get_location_and_verify_access from .access import has_access, get_location_and_verify_access
...@@ -33,9 +33,6 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \ ...@@ -33,9 +33,6 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \
from django_comment_common.utils import seed_permissions_roles from django_comment_common.utils import seed_permissions_roles
import datetime import datetime
from django.utils.timezone import UTC from django.utils.timezone import UTC
# TODO: should explicitly enumerate exports with __all__
__all__ = ['course_index', 'create_new_course', 'course_info', __all__ = ['course_index', 'create_new_course', 'course_info',
'course_info_updates', 'get_course_settings', 'course_info_updates', 'get_course_settings',
'course_config_graders_page', 'course_config_graders_page',
...@@ -84,7 +81,7 @@ def course_index(request, org, course, name): ...@@ -84,7 +81,7 @@ def course_index(request, org, course, name):
@expect_json @expect_json
def create_new_course(request): def create_new_course(request):
if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff: if not is_user_in_creator_group(request.user):
raise PermissionDenied() raise PermissionDenied()
# This logic is repeated in xmodule/modulestore/tests/factories.py # This logic is repeated in xmodule/modulestore/tests/factories.py
...@@ -230,7 +227,8 @@ def get_course_settings(request, org, course, name): ...@@ -230,7 +227,8 @@ def get_course_settings(request, org, course, name):
kwargs={"org": org, kwargs={"org": org,
"course": course, "course": course,
"name": name, "name": name,
"section": "details"}) "section": "details"}),
'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False)
}) })
......
...@@ -103,7 +103,7 @@ def clone_item(request): ...@@ -103,7 +103,7 @@ def clone_item(request):
@expect_json @expect_json
def delete_item(request): def delete_item(request):
item_location = request.POST['id'] item_location = request.POST['id']
item_loc = Location(item_location) item_location = Location(item_location)
# check permissions for this user within this course # check permissions for this user within this course
if not has_access(request.user, item_location): if not has_access(request.user, item_location):
...@@ -124,11 +124,11 @@ def delete_item(request): ...@@ -124,11 +124,11 @@ def delete_item(request):
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling # cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
if delete_all_versions: if delete_all_versions:
parent_locs = modulestore('direct').get_parent_locations(item_loc, None) parent_locs = modulestore('direct').get_parent_locations(item_location, None)
for parent_loc in parent_locs: for parent_loc in parent_locs:
parent = modulestore('direct').get_item(parent_loc) parent = modulestore('direct').get_item(parent_loc)
item_url = item_loc.url() item_url = item_location.url()
if item_url in parent.children: if item_url in parent.children:
children = parent.children children = parent.children
children.remove(item_url) children.remove(item_url)
......
...@@ -41,25 +41,25 @@ class CourseDetails(object): ...@@ -41,25 +41,25 @@ class CourseDetails(object):
course.enrollment_start = descriptor.enrollment_start course.enrollment_start = descriptor.enrollment_start
course.enrollment_end = descriptor.enrollment_end course.enrollment_end = descriptor.enrollment_end
temploc = course_location._replace(category='about', name='syllabus') temploc = course_location.replace(category='about', name='syllabus')
try: try:
course.syllabus = get_modulestore(temploc).get_item(temploc).data course.syllabus = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError: except ItemNotFoundError:
pass pass
temploc = temploc._replace(name='overview') temploc = temploc.replace(name='overview')
try: try:
course.overview = get_modulestore(temploc).get_item(temploc).data course.overview = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError: except ItemNotFoundError:
pass pass
temploc = temploc._replace(name='effort') temploc = temploc.replace(name='effort')
try: try:
course.effort = get_modulestore(temploc).get_item(temploc).data course.effort = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError: except ItemNotFoundError:
pass pass
temploc = temploc._replace(name='video') temploc = temploc.replace(name='video')
try: try:
raw_video = get_modulestore(temploc).get_item(temploc).data raw_video = get_modulestore(temploc).get_item(temploc).data
course.intro_video = CourseDetails.parse_video_tag(raw_video) course.intro_video = CourseDetails.parse_video_tag(raw_video)
...@@ -126,16 +126,16 @@ class CourseDetails(object): ...@@ -126,16 +126,16 @@ class CourseDetails(object):
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
# to make faster, could compare against db or could have client send over a list of which fields changed. # to make faster, could compare against db or could have client send over a list of which fields changed.
temploc = Location(course_location)._replace(category='about', name='syllabus') temploc = Location(course_location).replace(category='about', name='syllabus')
update_item(temploc, jsondict['syllabus']) update_item(temploc, jsondict['syllabus'])
temploc = temploc._replace(name='overview') temploc = temploc.replace(name='overview')
update_item(temploc, jsondict['overview']) update_item(temploc, jsondict['overview'])
temploc = temploc._replace(name='effort') temploc = temploc.replace(name='effort')
update_item(temploc, jsondict['effort']) update_item(temploc, jsondict['effort'])
temploc = temploc._replace(name='video') temploc = temploc.replace(name='video')
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video']) recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
update_item(temploc, recomposed_video_tag) update_item(temploc, recomposed_video_tag)
...@@ -153,9 +153,9 @@ class CourseDetails(object): ...@@ -153,9 +153,9 @@ class CourseDetails(object):
if not raw_video: if not raw_video:
return None return None
keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video) keystring_matcher = re.search(r'(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher is None: if keystring_matcher is None:
keystring_matcher = re.search('<?=\d+:[a-zA-Z0-9_-]+', raw_video) keystring_matcher = re.search(r'<?=\d+:[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher: if keystring_matcher:
return keystring_matcher.group(0) return keystring_matcher.group(0)
...@@ -174,10 +174,10 @@ class CourseDetails(object): ...@@ -174,10 +174,10 @@ class CourseDetails(object):
return result return result
# TODO move to a more general util? Is there a better way to do the isinstance model check? # TODO move to a more general util?
class CourseSettingsEncoder(json.JSONEncoder): class CourseSettingsEncoder(json.JSONEncoder):
def default(self, obj): def default(self, obj):
if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel): if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)):
return obj.__dict__ return obj.__dict__
elif isinstance(obj, Location): elif isinstance(obj, Location):
return obj.dict() return obj.dict()
......
from xmodule.modulestore import Location from xmodule.modulestore import Location
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xblock.core import Scope from xblock.core import Scope
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
......
...@@ -40,6 +40,21 @@ MODULESTORE = { ...@@ -40,6 +40,21 @@ MODULESTORE = {
'OPTIONS': MODULESTORE_OPTIONS 'OPTIONS': MODULESTORE_OPTIONS
} }
} }
CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db': 'acceptance_xcontent',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
}
}
# Set this up so that rake lms[acceptance] and running the # Set this up so that rake lms[acceptance] and running the
# harvest command both use the same (test) database # harvest command both use the same (test) database
# which they can flush without messing up your dev db # which they can flush without messing up your dev db
......
...@@ -21,7 +21,7 @@ Longer TODO: ...@@ -21,7 +21,7 @@ Longer TODO:
# We intentionally define lots of variables that aren't used, and # We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files # want to import all variables from base settings files
# pylint: disable=W0401, W0614 # pylint: disable=W0401, W0611, W0614
import sys import sys
import lms.envs.common import lms.envs.common
...@@ -235,8 +235,7 @@ PIPELINE_JS = { ...@@ -235,8 +235,7 @@ PIPELINE_JS = {
'source_filenames': sorted( 'source_filenames': sorted(
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') + rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') +
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js') rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js')
) + ['js/hesitate.js', 'js/base.js', ) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js',
'js/models/feedback.js', 'js/views/feedback.js',
'js/models/section.js', 'js/views/section.js', 'js/models/section.js', 'js/views/section.js',
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js', 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
'js/views/assets.js'], 'js/views/assets.js'],
......
...@@ -181,6 +181,6 @@ if SEGMENT_IO_KEY: ...@@ -181,6 +181,6 @@ if SEGMENT_IO_KEY:
##################################################################### #####################################################################
# Lastly, see if the developer has any local overrides. # Lastly, see if the developer has any local overrides.
try: try:
from .private import * from .private import * # pylint: disable=F0401
except ImportError: except ImportError:
pass pass
...@@ -7,9 +7,7 @@ ...@@ -7,9 +7,7 @@
# FORCE_SCRIPT_NAME = '/cms' # FORCE_SCRIPT_NAME = '/cms'
from .common import * from .common import *
from logsettings import get_logger_config
from .dev import * from .dev import *
import socket
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
......
...@@ -70,7 +70,7 @@ CONTENTSTORE = { ...@@ -70,7 +70,7 @@ CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': { 'OPTIONS': {
'host': 'localhost', 'host': 'localhost',
'db': 'test_xmodule', 'db': 'test_xcontent',
}, },
# allow for additional options that can be keyed on a name, e.g. 'trashcan' # allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': { 'ADDITIONAL_OPTIONS': {
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
"js/vendor/jquery.cookie.js", "js/vendor/jquery.cookie.js",
"js/vendor/json2.js", "js/vendor/json2.js",
"js/vendor/underscore-min.js", "js/vendor/underscore-min.js",
"js/vendor/underscore.string.min.js",
"js/vendor/backbone-min.js", "js/vendor/backbone-min.js",
"js/vendor/jquery.leanModal.min.js", "js/vendor/jquery.leanModal.min.js",
"js/vendor/sinon-1.7.1.js", "js/vendor/sinon-1.7.1.js",
......
describe "CMS.Models.SystemFeedback", ->
beforeEach ->
@model = new CMS.Models.SystemFeedback()
it "should have an empty message by default", ->
expect(@model.get("message")).toEqual("")
it "should have an empty title by default", ->
expect(@model.get("title")).toEqual("")
it "should not have an intent set by default", ->
expect(@model.get("intent")).toBeNull()
describe "CMS.Models.WarningMessage", ->
beforeEach ->
@model = new CMS.Models.WarningMessage()
it "should have the correct intent", ->
expect(@model.get("intent")).toEqual("warning")
describe "CMS.Models.ErrorMessage", ->
beforeEach ->
@model = new CMS.Models.ErrorMessage()
it "should have the correct intent", ->
expect(@model.get("intent")).toEqual("error")
describe "CMS.Models.ConfirmationMessage", ->
beforeEach ->
@model = new CMS.Models.ConfirmationMessage()
it "should have the correct intent", ->
expect(@model.get("intent")).toEqual("confirmation")
...@@ -18,11 +18,15 @@ $ -> ...@@ -18,11 +18,15 @@ $ ->
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) -> $(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
if ajaxSettings.notifyOnError is false if ajaxSettings.notifyOnError is false
return return
msg = new CMS.Models.ErrorMessage( if jqXHR.responseText
message = _.str.truncate(jqXHR.responseText, 300)
else
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
msg = new CMS.Views.Notification.Error(
"title": gettext("Studio's having trouble saving your work") "title": gettext("Studio's having trouble saving your work")
"message": jqXHR.responseText || gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") "message": message
) )
new CMS.Views.Notification({model: msg}) msg.show()
window.onTouchBasedDevice = -> window.onTouchBasedDevice = ->
navigator.userAgent.match /iPhone|iPod|iPad/i navigator.userAgent.match /iPhone|iPod|iPad/i
......
...@@ -25,7 +25,6 @@ $(document).ready(function() { ...@@ -25,7 +25,6 @@ $(document).ready(function() {
$newComponentTemplatePickers = $('.new-component-templates'); $newComponentTemplatePickers = $('.new-component-templates');
$newComponentButton = $('.new-component-button'); $newComponentButton = $('.new-component-button');
$spinner = $('<span class="spinner-in-field-icon"></span>'); $spinner = $('<span class="spinner-in-field-icon"></span>');
$body.bind('keyup', onKeyUp);
$('.expand-collapse-icon').bind('click', toggleSubmodules); $('.expand-collapse-icon').bind('click', toggleSubmodules);
$('.visibility-options').bind('change', setVisibility); $('.visibility-options').bind('change', setVisibility);
...@@ -413,12 +412,6 @@ function hideModal(e) { ...@@ -413,12 +412,6 @@ function hideModal(e) {
} }
} }
function onKeyUp(e) {
if (e.which == 87) {
$body.toggleClass('show-wip hide-wip');
}
}
function toggleSock(e) { function toggleSock(e) {
e.preventDefault(); e.preventDefault();
......
CMS.Models.SystemFeedback = Backbone.Model.extend({
defaults: {
"intent": null, // "warning", "confirmation", "error", "announcement", "step-required", etc
"title": "",
"message": ""
/* could also have an "actions" hash: here is an example demonstrating
the expected structure
"actions": {
"primary": {
"text": "Save",
"class": "action-save",
"click": function() {
// do something when Save is clicked
// `this` refers to the model
}
},
"secondary": [
{
"text": "Cancel",
"class": "action-cancel",
"click": function() {}
}, {
"text": "Discard Changes",
"class": "action-discard",
"click": function() {}
}
]
}
*/
}
});
CMS.Models.WarningMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "warning"
})
});
CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "error"
})
});
CMS.Models.ConfirmAssetDeleteMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "warning"
})
});
CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "confirmation"
})
});
...@@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({ ...@@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({
}, },
showNotification: function() { showNotification: function() {
if(!this.msg) { if(!this.msg) {
this.msg = new CMS.Models.SystemFeedback({ this.msg = new CMS.Views.Notification.Saving({
intent: "saving", title: gettext("Saving&hellip;"),
title: gettext("Saving&hellip;")
});
}
if(!this.msgView) {
this.msgView = new CMS.Views.Notification({
model: this.msg,
closeIcon: false, closeIcon: false,
minShown: 1250 minShown: 1250
}); });
} }
this.msgView.show(); this.msg.show();
}, },
hideNotification: function() { hideNotification: function() {
if(!this.msgView) { return; } if(!this.msg) { return; }
this.msgView.hide(); this.msg.hide();
} }
}); });
...@@ -9,7 +9,7 @@ function removeAsset(e){ ...@@ -9,7 +9,7 @@ function removeAsset(e){
e.preventDefault(); e.preventDefault();
var that = this; var that = this;
var msg = new CMS.Models.ConfirmAssetDeleteMessage({ var msg = new CMS.Views.Prompt.Confirmation({
title: gettext("Delete File Confirmation"), title: gettext("Delete File Confirmation"),
message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"), message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"),
actions: { actions: {
...@@ -17,15 +17,17 @@ function removeAsset(e){ ...@@ -17,15 +17,17 @@ function removeAsset(e){
text: gettext("OK"), text: gettext("OK"),
click: function(view) { click: function(view) {
// call the back-end to actually remove the asset // call the back-end to actually remove the asset
$.post(view.model.get('remove_asset_url'), var url = $('.asset-library').data('remove-asset-callback-url');
{ 'location': view.model.get('asset_location') }, var row = $(that).closest('tr');
$.post(url,
{ 'location': row.data('id') },
function() { function() {
// show the post-commit confirmation // show the post-commit confirmation
$(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false'); $(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false');
view.model.get('row_to_remove').remove(); row.remove();
analytics.track('Deleted Asset', { analytics.track('Deleted Asset', {
'course': course_location_analytics, 'course': course_location_analytics,
'id': view.model.get('asset_location') 'id': row.data('id')
}); });
} }
); );
...@@ -38,24 +40,9 @@ function removeAsset(e){ ...@@ -38,24 +40,9 @@ function removeAsset(e){
view.hide(); view.hide();
} }
}] }]
}, }
remove_asset_url: $('.asset-library').data('remove-asset-callback-url'),
asset_location: $(this).closest('tr').data('id'),
row_to_remove: $(this).closest('tr')
}); });
return msg.show();
// workaround for now. We can't spawn multiple instances of the Prompt View
// so for now, a bit of hackery to just make sure we have a single instance
// note: confirm_delete_prompt is in asset_index.html
if (confirm_delete_prompt === null)
confirm_delete_prompt = new CMS.Views.Prompt({model: msg});
else
{
confirm_delete_prompt.model = msg;
confirm_delete_prompt.show();
}
return;
} }
function showUploadModal(e) { function showUploadModal(e) {
...@@ -125,4 +112,4 @@ function displayFinishedUpload(xhr) { ...@@ -125,4 +112,4 @@ function displayFinishedUpload(xhr) {
'course': course_location_analytics, 'course': course_location_analytics,
'asset_url': resp.url 'asset_url': resp.url
}); });
} }
\ No newline at end of file
CMS.Views.Alert = Backbone.View.extend({ CMS.Views.SystemFeedback = Backbone.View.extend({
options: { options: {
type: "alert", title: "",
message: "",
intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc
type: null, // "alert", "notification", or "prompt": set by subclass
shown: true, // is this view currently being shown? shown: true, // is this view currently being shown?
icon: true, // should we render an icon related to the message intent? icon: true, // should we render an icon related to the message intent?
closeIcon: true, // should we render a close button in the top right corner? closeIcon: true, // should we render a close button in the top right corner?
minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds) minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds)
maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds) maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds)
/* Could also have an "actions" hash: here is an example demonstrating
the expected structure. For each action, by default the framework
will call preventDefault on the click event before the function is
run; to make it not do that, just pass `preventDefault: false` in
the action object.
actions: {
primary: {
"text": "Save",
"class": "action-save",
"click": function(view) {
// do something when Save is clicked
}
},
secondary: [
{
"text": "Cancel",
"class": "action-cancel",
"click": function(view) {}
}, {
"text": "Discard Changes",
"class": "action-discard",
"click": function(view) {}
}
]
}
*/
}, },
initialize: function() { initialize: function() {
if(!this.options.type) {
throw "SystemFeedback: type required (given " +
JSON.stringify(this.options) + ")";
}
if(!this.options.intent) {
throw "SystemFeedback: intent required (given " +
JSON.stringify(this.options) + ")";
}
var tpl = $("#system-feedback-tpl").text(); var tpl = $("#system-feedback-tpl").text();
if(!tpl) { if(!tpl) {
console.error("Couldn't load system-feedback template"); console.error("Couldn't load system-feedback template");
} }
this.template = _.template(tpl); this.template = _.template(tpl);
this.setElement($("#page-"+this.options.type)); this.setElement($("#page-"+this.options.type));
this.listenTo(this.model, 'change', this.render); // handle single "secondary" action
return this.show(); if (this.options.actions && this.options.actions.secondary &&
}, !_.isArray(this.options.actions.secondary)) {
render: function() { this.options.actions.secondary = [this.options.actions.secondary];
var attrs = $.extend({}, this.options, this.model.attributes); }
this.$el.html(this.template(attrs));
return this; return this;
}, },
events: { // public API: show() and hide()
"click .action-close": "hide",
"click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick"
},
show: function() { show: function() {
clearTimeout(this.hideTimeout); clearTimeout(this.hideTimeout);
this.options.shown = true; this.options.shown = true;
this.shownAt = new Date(); this.shownAt = new Date();
this.render(); this.render();
if($.isNumeric(this.options.maxShown)) { if($.isNumeric(this.options.maxShown)) {
this.hideTimeout = setTimeout($.proxy(this.hide, this), this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.maxShown); this.options.maxShown);
} }
return this; return this;
...@@ -43,7 +77,7 @@ CMS.Views.Alert = Backbone.View.extend({ ...@@ -43,7 +77,7 @@ CMS.Views.Alert = Backbone.View.extend({
this.options.minShown > new Date() - this.shownAt) this.options.minShown > new Date() - this.shownAt)
{ {
clearTimeout(this.hideTimeout); clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout($.proxy(this.hide, this), this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.minShown - (new Date() - this.shownAt)); this.options.minShown - (new Date() - this.shownAt));
} else { } else {
this.options.shown = false; this.options.shown = false;
...@@ -52,40 +86,70 @@ CMS.Views.Alert = Backbone.View.extend({ ...@@ -52,40 +86,70 @@ CMS.Views.Alert = Backbone.View.extend({
} }
return this; return this;
}, },
primaryClick: function() { // the rest of the API should be considered semi-private
var actions = this.model.get("actions"); events: {
"click .action-close": "hide",
"click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick"
},
render: function() {
// there can be only one active view of a given type at a time: only
// one alert, only one notification, only one prompt. Therefore, we'll
// use a singleton approach.
var parent = CMS.Views[_.str.capitalize(this.options.type)];
if(parent && parent.active && parent.active !== this) {
parent.active.stopListening();
parent.active.undelegateEvents();
}
this.$el.html(this.template(this.options));
parent.active = this;
return this;
},
primaryClick: function(event) {
var actions = this.options.actions;
if(!actions) { return; } if(!actions) { return; }
var primary = actions.primary; var primary = actions.primary;
if(!primary) { return; } if(!primary) { return; }
if(primary.preventDefault !== false) {
event.preventDefault();
}
if(primary.click) { if(primary.click) {
primary.click.call(this.model, this); primary.click.call(event.target, this, event);
} }
}, },
secondaryClick: function(e) { secondaryClick: function(event) {
var actions = this.model.get("actions"); var actions = this.options.actions;
if(!actions) { return; } if(!actions) { return; }
var secondaryList = actions.secondary; var secondaryList = actions.secondary;
if(!secondaryList) { return; } if(!secondaryList) { return; }
// which secondary action was clicked? // which secondary action was clicked?
var i = 0; // default to the first secondary action (easier for testing) var i = 0; // default to the first secondary action (easier for testing)
if(e && e.target) { if(event && event.target) {
i = _.indexOf(this.$(".action-secondary"), e.target); i = _.indexOf(this.$(".action-secondary"), event.target);
}
var secondary = secondaryList[i];
if(secondary.preventDefault !== false) {
event.preventDefault();
} }
var secondary = this.model.get("actions").secondary[i];
if(secondary.click) { if(secondary.click) {
secondary.click.call(this.model, this); secondary.click.call(event.target, this, event);
} }
} }
}); });
CMS.Views.Notification = CMS.Views.Alert.extend({ CMS.Views.Alert = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.Alert.prototype.options, { options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "alert"
})
});
CMS.Views.Notification = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "notification", type: "notification",
closeIcon: false closeIcon: false
}) })
}); });
CMS.Views.Prompt = CMS.Views.Alert.extend({ CMS.Views.Prompt = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.Alert.prototype.options, { options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "prompt", type: "prompt",
closeIcon: false, closeIcon: false,
icon: false icon: false
...@@ -98,6 +162,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({ ...@@ -98,6 +162,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({
$body.removeClass('prompt-is-shown'); $body.removeClass('prompt-is-shown');
} }
// super() in Javascript has awkward syntax :( // super() in Javascript has awkward syntax :(
return CMS.Views.Alert.prototype.render.apply(this, arguments); return CMS.Views.SystemFeedback.prototype.render.apply(this, arguments);
} }
}); });
// create CMS.Views.Alert.Warning, CMS.Views.Notification.Confirmation,
// CMS.Views.Prompt.StepRequired, etc
var capitalCamel, types, intents;
capitalCamel = _.compose(_.str.capitalize, _.str.camelize);
types = ["alert", "notification", "prompt"];
intents = ["warning", "error", "confirmation", "announcement", "step-required", "help", "saving"];
_.each(types, function(type) {
_.each(intents, function(intent) {
// "class" is a reserved word in Javascript, so use "klass" instead
var klass, subklass;
klass = CMS.Views[capitalCamel(type)];
subklass = klass.extend({
options: $.extend({}, klass.prototype.options, {
type: type,
intent: intent
})
});
klass[capitalCamel(intent)] = subklass;
});
});
...@@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({ ...@@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({
showInvalidMessage: function(model, error, options) { showInvalidMessage: function(model, error, options) {
model.set("name", model.previous("name")); model.set("name", model.previous("name"));
var that = this; var that = this;
var msg = new CMS.Models.ErrorMessage({ var prompt = new CMS.Views.Prompt.Error({
title: gettext("Your change could not be saved"), title: gettext("Your change could not be saved"),
message: error, message: error,
actions: { actions: {
...@@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({ ...@@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({
} }
} }
}); });
new CMS.Views.Prompt({model: msg}); prompt.show();
} }
}); });
...@@ -20,9 +20,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ ...@@ -20,9 +20,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
self.render(); self.render();
} }
); );
// because these are outside of this.$el, they can't be in the event hash
$('.save-button').on('click', this, this.saveView);
$('.cancel-button').on('click', this, this.revertView);
this.listenTo(this.model, 'invalid', this.handleValidationError); this.listenTo(this.model, 'invalid', this.handleValidationError);
}, },
render: function() { render: function() {
...@@ -45,7 +42,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ ...@@ -45,7 +42,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
var policyValues = listEle$.find('.json'); var policyValues = listEle$.find('.json');
_.each(policyValues, this.attachJSONEditor, this); _.each(policyValues, this.attachJSONEditor, this);
this.showMessage();
return this; return this;
}, },
attachJSONEditor : function (textarea) { attachJSONEditor : function (textarea) {
...@@ -61,7 +57,9 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ ...@@ -61,7 +57,9 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
mode: "application/json", lineNumbers: false, lineWrapping: false, mode: "application/json", lineNumbers: false, lineWrapping: false,
onChange: function(instance, changeobj) { onChange: function(instance, changeobj) {
// this event's being called even when there's no change :-( // this event's being called even when there's no change :-(
if (instance.getValue() !== oldValue) self.showSaveCancelButtons(); if (instance.getValue() !== oldValue && !self.notificationBarShowing) {
self.showNotificationBar();
}
}, },
onFocus : function(mirror) { onFocus : function(mirror) {
$(textarea).parent().children('label').addClass("is-focused"); $(textarea).parent().children('label').addClass("is-focused");
...@@ -99,59 +97,65 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ ...@@ -99,59 +97,65 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
} }
}); });
}, },
showMessage: function (type) { showNotificationBar: function() {
$(".wrapper-alert").removeClass("is-shown"); var self = this;
if (type) { 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.")
if (type === this.error_saving) { var confirm = new CMS.Views.Notification.Warning({
$(".wrapper-alert-error").addClass("is-shown").attr('aria-hidden','false'); title: gettext("You've Made Some Changes"),
} message: message,
else if (type === this.successful_changes) { actions: {
$(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false'); primary: {
this.hideSaveCancelButtons(); "text": gettext("Save Changes"),
} "class": "action-save",
} "click": function() {
else { self.saveView();
// This is the case of the page first rendering, or when Cancel is pressed. confirm.hide();
this.hideSaveCancelButtons(); self.notificationBarShowing = false;
} }
}, },
showSaveCancelButtons: function(event) { secondary: [{
if (!this.notificationBarShowing) { "text": gettext("Cancel"),
this.$el.find(".message-status").removeClass("is-shown"); "class": "action-cancel",
$('.wrapper-notification').removeClass('is-hiding').addClass('is-shown').attr('aria-hidden','false'); "click": function() {
this.notificationBarShowing = true; self.revertView();
} confirm.hide();
}, self.notificationBarShowing = false;
hideSaveCancelButtons: function() { }
if (this.notificationBarShowing) { }]
$('.wrapper-notification').removeClass('is-shown').addClass('is-hiding').attr('aria-hidden','true'); }});
this.notificationBarShowing = false; this.notificationBarShowing = true;
confirm.show();
if(this.saved) {
this.saved.hide();
} }
}, },
saveView : function(event) { saveView : function() {
window.CmsUtils.smoothScrollTop(event);
// TODO one last verification scan: // TODO one last verification scan:
// call validateKey on each to ensure proper format // call validateKey on each to ensure proper format
// check for dupes // check for dupes
var self = event.data; var self = this;
self.model.save({}, this.model.save({},
{ {
success : function() { success : function() {
self.render(); self.render();
self.showMessage(self.successful_changes); var message = gettext("Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs.");
self.saved = new CMS.Views.Alert.Confirmation({
title: gettext("Your policy changes have been saved."),
message: message,
closeIcon: false
});
self.saved.show();
analytics.track('Saved Advanced Settings', { analytics.track('Saved Advanced Settings', {
'course': course_location_analytics 'course': course_location_analytics
}); });
} }
}); });
}, },
revertView : function(event) { revertView : function() {
event.preventDefault(); var self = this;
var self = event.data; this.model.deleteKeys = [];
self.model.deleteKeys = []; this.model.clear({silent : true});
self.model.clear({silent : true}); this.model.fetch({
self.model.fetch({
success : function() { self.render(); }, success : function() { self.render(); },
reset: true reset: true
}); });
......
// studio - elements - system help // 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;
@include border-radius(($baseline/10));
.title {
@extend .t-title7;
margin-bottom: ($baseline/4);
font-weight: 600;
}
.copy {
@extend .t-copy-sub1;
@include transition(opacity 0.25s ease-in-out 0);
opacity: 0.75;
}
strong {
font-weight: 600;
}
&:hover {
.copy {
opacity: 1.0;
}
}
}
// particular warnings around a workflow for something
.notice-workflow {
background: $yellow-l5;
.copy {
color: $gray-d1;
}
}
...@@ -21,7 +21,7 @@ body.course.settings { ...@@ -21,7 +21,7 @@ body.course.settings {
font-size: 14px; font-size: 14px;
} }
.message-status { .message-status {
display: none; display: none;
@include border-top-radius(2px); @include border-top-radius(2px);
@include box-sizing(border-box); @include box-sizing(border-box);
...@@ -52,6 +52,12 @@ body.course.settings { ...@@ -52,6 +52,12 @@ body.course.settings {
} }
} }
// notices - used currently for edx mktg
.notice-workflow {
margin-top: ($baseline);
}
// in form - elements // in form - elements
.group-settings { .group-settings {
margin: 0 0 ($baseline*2) 0; margin: 0 0 ($baseline*2) 0;
......
...@@ -8,11 +8,6 @@ ...@@ -8,11 +8,6 @@
<%block name="jsextra"> <%block name="jsextra">
<script src="${static.url('js/vendor/mustache.js')}"></script> <script src="${static.url('js/vendor/mustache.js')}"></script>
<script type='text/javascript'>
// we just want a singleton
confirm_delete_prompt = null;
</script>
</%block> </%block>
<%block name="content"> <%block name="content">
...@@ -98,7 +93,7 @@ ...@@ -98,7 +93,7 @@
</td> </td>
<td class="delete-col"> <td class="delete-col">
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a> <a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
</td> </td>
</tr> </tr>
% endfor % endfor
</tbody> </tbody>
......
...@@ -38,6 +38,7 @@ ...@@ -38,6 +38,7 @@
<script type="text/javascript" src="/jsi18n/"></script> <script type="text/javascript" src="/jsi18n/"></script>
<script type="text/javascript" src="${static.url('js/vendor/json2.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/json2.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/underscore.string.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/markitup/jquery.markitup.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/markitup/jquery.markitup.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/markitup/sets/wiki/set.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/markitup/sets/wiki/set.js')}"></script>
...@@ -54,15 +55,12 @@ ...@@ -54,15 +55,12 @@
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script>
<script type="text/javascript" src="//www.youtube.com/player_api"></script> <script type="text/javascript" src="//www.youtube.com/player_api"></script>
<script src="${static.url('js/models/feedback.js')}"></script>
<script src="${static.url('js/views/feedback.js')}"></script> <script src="${static.url('js/views/feedback.js')}"></script>
<!-- view --> <!-- view -->
<div class="wrapper wrapper-view"> <div class="wrapper wrapper-view">
<%include file="widgets/header.html" /> <%include file="widgets/header.html" />
## remove this block after advanced settings notification is rewritten
<%block name="view_alerts"></%block>
<div id="page-alert"></div> <div id="page-alert"></div>
<%block name="content"></%block> <%block name="content"></%block>
...@@ -74,13 +72,9 @@ ...@@ -74,13 +72,9 @@
<%include file="widgets/footer.html" /> <%include file="widgets/footer.html" />
<%include file="widgets/tender.html" /> <%include file="widgets/tender.html" />
## remove this block after advanced settings notification is rewritten
<%block name="view_notifications"></%block>
<div id="page-notification"></div> <div id="page-notification"></div>
</div> </div>
## remove this block after advanced settings notification is rewritten
<%block name="view_prompts"></%block>
<div id="page-prompt"></div> <div id="page-prompt"></div>
<%block name="jsextra"></%block> <%block name="jsextra"></%block>
</body> </body>
......
Thank you for signing up for edX edge! To activate your account, Thank you for signing up for edX Studio! To activate your account,
please copy and paste this address into your web browser's please copy and paste this address into your web browser's
address bar: address bar:
......
Your account for edX edge Your account for edX Studio
...@@ -104,60 +104,3 @@ editor.render(); ...@@ -104,60 +104,3 @@ editor.render();
</section> </section>
</div> </div>
</%block> </%block>
<%block name="view_notifications">
<!-- notification: change has been made and a save is needed -->
<div class="wrapper wrapper-notification wrapper-notification-warning" aria-hidden="true" role="dialog" aria-labelledby="notification-changesMade-title" aria-describedby="notification-changesMade-description">
<div class="notification warning has-actions">
<i class="icon-warning-sign"></i>
<div class="copy">
<h2 class="title title-3" id="notification-changesMade-title">You've Made Some Changes</h2>
<p id="notification-changesMade-description">Your changes will not take effect until you <strong>save your progress</strong>. Take care with key and value formatting, as validation is <strong>not implemented</strong>.</p>
</div>
<nav class="nav-actions">
<h3 class="sr">Notification Actions</h3>
<ul>
<li class="nav-item">
<a href="" class="action-primary save-button">Save Changes</a>
</li>
<li class="nav-item">
<a href="" class="action-secondary cancel-button">Cancel</a>
</li>
</ul>
</nav>
</div>
</div>
</%block>
<%block name="view_alerts">
<!-- alert: save confirmed with close -->
<div class="wrapper wrapper-alert wrapper-alert-confirmation" role="status">
<div class="alert confirmation">
<i class="icon-ok"></i>
<div class="copy">
<h2 class="title title-3">Your policy changes have been saved.</h2>
<p>Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs.</p>
</div>
<a href="" rel="view" class="action action-alert-close">
<i class="icon-remove-sign"></i>
<span class="label">close alert</span>
</a>
</div>
</div>
<!-- alert: error -->
<div class="wrapper wrapper-alert wrapper-alert-error" role="status">
<div class="alert error">
<i class="icon-warning-sign"></i>
<div class="copy">
<h2 class="title title-3">There was an error saving your information</h2>
<p>Please see the error below and correct it to ensure there are no problems in rendering your course.</p>
</div>
</div>
</div>
</%block>
from django.conf import settings from django.conf import settings
from django.conf.urls import patterns, include, url from django.conf.urls import patterns, include, url
# Import this file so it can do its work, even though we don't use the name.
# pylint: disable=W0611
from . import one_time_startup from . import one_time_startup
# Uncomment the next two lines to enable the admin: # Uncomment the next two lines to enable the admin:
......
...@@ -12,7 +12,6 @@ from django.core.cache import cache ...@@ -12,7 +12,6 @@ from django.core.cache import cache
from django.db import DEFAULT_DB_ALIAS from django.db import DEFAULT_DB_ALIAS
from . import app_settings from . import app_settings
from xmodule.contentstore.content import StaticContent
def get_instance(model, instance_or_pk, timeout=None, using=None): def get_instance(model, instance_or_pk, timeout=None, using=None):
......
...@@ -3,7 +3,6 @@ This file contains the logic for cohort groups, as exposed internally to the ...@@ -3,7 +3,6 @@ This file contains the logic for cohort groups, as exposed internally to the
forums, and to the cohort admin views. forums, and to the cohort admin views.
""" """
from django.contrib.auth.models import User
from django.http import Http404 from django.http import Http404
import logging import logging
import random import random
...@@ -27,7 +26,7 @@ def local_random(): ...@@ -27,7 +26,7 @@ def local_random():
""" """
# ironic, isn't it? # ironic, isn't it?
global _local_random global _local_random
if _local_random is None: if _local_random is None:
_local_random = random.Random() _local_random = random.Random()
......
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.context_processors import csrf
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseForbidden, Http404 from django.http import HttpResponse
from django.shortcuts import redirect
import json import json
import logging import logging
import re import re
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response
from .models import CourseUserGroup
from . import cohorts from . import cohorts
import track.views
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
......
...@@ -3,7 +3,7 @@ import json ...@@ -3,7 +3,7 @@ import json
import logging import logging
import random import random
import re import re
import string import string # pylint: disable=W0402
import fnmatch import fnmatch
from textwrap import dedent from textwrap import dedent
...@@ -145,6 +145,7 @@ def external_login_or_signup(request, ...@@ -145,6 +145,7 @@ def external_login_or_signup(request,
eamap.save() eamap.save()
log.info("External_Auth login_or_signup for %s : %s : %s : %s" % (external_domain, external_id, email, fullname))
internal_user = eamap.user internal_user = eamap.user
if internal_user is None: if internal_user is None:
if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): if settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
...@@ -156,24 +157,25 @@ def external_login_or_signup(request, ...@@ -156,24 +157,25 @@ def external_login_or_signup(request,
eamap.user = link_user eamap.user = link_user
eamap.save() eamap.save()
internal_user = link_user internal_user = link_user
log.debug('Linking existing account for %s' % eamap.external_email) log.info('SHIB: Linking existing account for %s' % eamap.external_email)
# now pass through to log in # now pass through to log in
else: else:
# otherwise, set external_email to '' to ask for a new one at user signup # otherwise, there must have been an error, b/c we've already linked a user with these external
eamap.external_email = '' # creds
eamap.save() failure_msg = _(dedent("""
log.debug('User with external login found for %s, asking for new email during signup' % email) You have already created an account using an external login like WebAuth or Shibboleth.
return signup(request, eamap) Please contact %s for support """
% getattr(settings, 'TECH_SUPPORT_EMAIL', 'techsupport@class.stanford.edu')))
return default_render_failure(request, failure_msg)
except User.DoesNotExist: except User.DoesNotExist:
log.debug('No user for %s yet, doing signup' % eamap.external_email) log.info('SHIB: No user for %s yet, doing signup' % eamap.external_email)
return signup(request, eamap) return signup(request, eamap)
else: else:
log.debug('No user for %s yet, doing signup' % eamap.external_email) log.info('No user for %s yet, doing signup' % eamap.external_email)
return signup(request, eamap) return signup(request, eamap)
# We trust shib's authentication, so no need to authenticate using the password again # We trust shib's authentication, so no need to authenticate using the password again
if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): if settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
uname = internal_user.username
user = internal_user user = internal_user
# Assuming this 'AUTHENTICATION_BACKENDS' is set in settings, which I think is safe # Assuming this 'AUTHENTICATION_BACKENDS' is set in settings, which I think is safe
if settings.AUTHENTICATION_BACKENDS: if settings.AUTHENTICATION_BACKENDS:
...@@ -181,6 +183,7 @@ def external_login_or_signup(request, ...@@ -181,6 +183,7 @@ def external_login_or_signup(request,
else: else:
auth_backend = 'django.contrib.auth.backends.ModelBackend' auth_backend = 'django.contrib.auth.backends.ModelBackend'
user.backend = auth_backend user.backend = auth_backend
log.info('SHIB: Logging in linked user %s' % user.email)
else: else:
uname = internal_user.username uname = internal_user.username
user = authenticate(username=uname, password=eamap.internal_password) user = authenticate(username=uname, password=eamap.internal_password)
...@@ -194,14 +197,13 @@ def external_login_or_signup(request, ...@@ -194,14 +197,13 @@ def external_login_or_signup(request,
# TODO: improve error page # TODO: improve error page
msg = 'Account not yet activated: please look for link in your email' msg = 'Account not yet activated: please look for link in your email'
return default_render_failure(request, msg) return default_render_failure(request, msg)
login(request, user) login(request, user)
request.session.set_expiry(0) request.session.set_expiry(0)
# Now to try enrollment # Now to try enrollment
# Need to special case Shibboleth here because it logs in via a GET. # Need to special case Shibboleth here because it logs in via a GET.
# testing request.method for extra paranoia # testing request.method for extra paranoia
if 'shib:' in external_domain and request.method == 'GET': if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in external_domain and request.method == 'GET':
enroll_request = make_shib_enrollment_request(request) enroll_request = make_shib_enrollment_request(request)
student_views.try_change_enrollment(enroll_request) student_views.try_change_enrollment(enroll_request)
else: else:
...@@ -243,8 +245,10 @@ def signup(request, eamap=None): ...@@ -243,8 +245,10 @@ def signup(request, eamap=None):
'ask_for_tos': True, 'ask_for_tos': True,
} }
# Can't have terms of service for Stanford users, according to Stanford's Office of General Counsel # Some openEdX instances can't have terms of service for shib users, like
if settings.MITX_FEATURES['AUTH_USE_SHIB'] and ('stanford' in eamap.external_domain): # according to Stanford's Office of General Counsel
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') and \
('shib' in eamap.external_domain):
context['ask_for_tos'] = False context['ask_for_tos'] = False
# detect if full name is blank and ask for it from user # detect if full name is blank and ask for it from user
...@@ -257,7 +261,7 @@ def signup(request, eamap=None): ...@@ -257,7 +261,7 @@ def signup(request, eamap=None):
except ValidationError: except ValidationError:
context['ask_for_email'] = True context['ask_for_email'] = True
log.debug('Doing signup for %s' % eamap.external_email) log.info('EXTAUTH: Doing signup for %s' % eamap.external_id)
return student_views.register_user(request, extra_context=context) return student_views.register_user(request, extra_context=context)
...@@ -371,7 +375,7 @@ def ssl_login(request): ...@@ -371,7 +375,7 @@ def ssl_login(request):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Shibboleth (Stanford and others. Uses *Apache* environment variables) # Shibboleth (Stanford and others. Uses *Apache* environment variables)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def shib_login(request, retfun=None): def shib_login(request):
""" """
Uses Apache's REMOTE_USER environment variable as the external id. Uses Apache's REMOTE_USER environment variable as the external id.
This in turn typically uses EduPersonPrincipalName This in turn typically uses EduPersonPrincipalName
...@@ -385,29 +389,31 @@ def shib_login(request, retfun=None): ...@@ -385,29 +389,31 @@ def shib_login(request, retfun=None):
""")) """))
if not request.META.get('REMOTE_USER'): if not request.META.get('REMOTE_USER'):
log.error("SHIB: no REMOTE_USER found in request.META")
return default_render_failure(request, shib_error_msg)
elif not request.META.get('Shib-Identity-Provider'):
log.error("SHIB: no Shib-Identity-Provider in request.META")
return default_render_failure(request, shib_error_msg) return default_render_failure(request, shib_error_msg)
else: else:
#if we get here, the user has authenticated properly #if we get here, the user has authenticated properly
attrs = ['REMOTE_USER', 'givenName', 'sn', 'mail', shib = {attr: request.META.get(attr, '')
'Shib-Identity-Provider'] for attr in ['REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider']}
shib = {}
for attr in attrs:
shib[attr] = request.META.get(attr, '')
#Clean up first name, last name, and email address #Clean up first name, last name, and email address
#TODO: Make this less hardcoded re: format, but split will work #TODO: Make this less hardcoded re: format, but split will work
#even if ";" is not present since we are accessing 1st element #even if ";" is not present since we are accessing 1st element
shib['sn'] = shib['sn'].split(";")[0].strip().capitalize() shib['sn'] = shib['sn'].split(";")[0].strip().capitalize().decode('utf-8')
shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize() shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize().decode('utf-8')
log.info("SHIB creds returned: %r" % shib)
return external_login_or_signup(request, return external_login_or_signup(request,
external_id=shib['REMOTE_USER'], external_id=shib['REMOTE_USER'],
external_domain="shib:" + shib['Shib-Identity-Provider'], external_domain="shib:" + shib['Shib-Identity-Provider'],
credentials=shib, credentials=shib,
email=shib['mail'], email=shib['mail'],
fullname="%s %s" % (shib['givenName'], shib['sn']), fullname=u'%s %s' % (shib['givenName'], shib['sn']),
retfun=retfun) )
def make_shib_enrollment_request(request): def make_shib_enrollment_request(request):
......
from django.conf.urls import * from django.conf.urls import url, patterns
urlpatterns = patterns('', # nopep8 urlpatterns = patterns('', # nopep8
url(r'^$', 'heartbeat.views.heartbeat', name='heartbeat'), url(r'^$', 'heartbeat.views.heartbeat', name='heartbeat'),
......
...@@ -7,7 +7,6 @@ from django.template.loaders.filesystem import Loader as FilesystemLoader ...@@ -7,7 +7,6 @@ from django.template.loaders.filesystem import Loader as FilesystemLoader
from django.template.loaders.app_directories import Loader as AppDirectoriesLoader from django.template.loaders.app_directories import Loader as AppDirectoriesLoader
from mitxmako.template import Template from mitxmako.template import Template
import mitxmako.middleware
import tempdir import tempdir
......
...@@ -6,7 +6,6 @@ from django.conf import settings ...@@ -6,7 +6,6 @@ from django.conf import settings
import json import json
import logging import logging
import os import os
import sys
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
......
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
django admin pages for courseware model django admin pages for courseware model
''' '''
from student.models import * from student.models import UserProfile, UserTestGroup, CourseEnrollmentAllowed
from student.models import CourseEnrollment, Registration, PendingNameChange
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import User
admin.site.register(UserProfile) admin.site.register(UserProfile)
......
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.hashers import UNUSABLE_PASSWORD
class PasswordResetFormNoActive(PasswordResetForm):
def clean_email(self):
"""
This is a literal copy from Django 1.4.5's django.contrib.auth.forms.PasswordResetForm
Except removing the requirement of active users
Validates that a user exists with the given email address.
"""
email = self.cleaned_data["email"]
#The line below contains the only change, removing is_active=True
self.users_cache = User.objects.filter(email__iexact=email)
if not len(self.users_cache):
raise forms.ValidationError(self.error_messages['unknown'])
if any((user.password == UNUSABLE_PASSWORD)
for user in self.users_cache):
raise forms.ValidationError(self.error_messages['unusable'])
return email
...@@ -11,12 +11,7 @@ ...@@ -11,12 +11,7 @@
import datetime import datetime
import json import json
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from student.models import UserProfile from student.models import UserProfile
......
...@@ -3,17 +3,11 @@ ...@@ -3,17 +3,11 @@
## See export for more info ## See export for more info
import datetime
import json import json
import dateutil.parser import dateutil.parser
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from student.models import UserProfile from student.models import UserProfile
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
......
...@@ -2,9 +2,7 @@ ...@@ -2,9 +2,7 @@
## A script to create some dummy users ## A script to create some dummy users
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from student.models import CourseEnrollment
from django.contrib.auth.models import User
from student.models import UserProfile, CourseEnrollment
from student.views import _do_create_account, get_random_post_override from student.views import _do_create_account, get_random_post_override
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
......
import os.path import os.path
import time import time
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
...@@ -40,7 +37,6 @@ rate -- messages per second ...@@ -40,7 +37,6 @@ rate -- messages per second
self.log_file.write(datetime.datetime.utcnow().isoformat() + ' -- ' + text + '\n') self.log_file.write(datetime.datetime.utcnow().isoformat() + ' -- ' + text + '\n')
def handle(self, *args, **options): def handle(self, *args, **options):
global log_file
(user_file, message_base, logfilename, ratestr) = args (user_file, message_base, logfilename, ratestr) = args
users = [u.strip() for u in open(user_file).readlines()] users = [u.strip() for u in open(user_file).readlines()]
......
...@@ -2,7 +2,7 @@ from optparse import make_option ...@@ -2,7 +2,7 @@ from optparse import make_option
from json import dump from json import dump
from datetime import datetime from datetime import datetime
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand
from student.models import TestCenterRegistration from student.models import TestCenterRegistration
......
...@@ -3,11 +3,8 @@ import csv ...@@ -3,11 +3,8 @@ import csv
from zipfile import ZipFile, is_zipfile from zipfile import ZipFile, is_zipfile
from time import strptime, strftime from time import strptime, strftime
from collections import OrderedDict
from datetime import datetime from datetime import datetime
from os.path import isdir from dogapi import dog_http_api
from optparse import make_option
from dogapi import dog_http_api, dog_stats_api
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.conf import settings from django.conf import settings
......
...@@ -26,7 +26,7 @@ class Command(BaseCommand): ...@@ -26,7 +26,7 @@ class Command(BaseCommand):
raise CommandError('Usage is set_staff {0}'.format(self.args)) raise CommandError('Usage is set_staff {0}'.format(self.args))
for user in args: for user in args:
if re.match('[^@]+@[^@]+\.[^@]+', user): if re.match(r'[^@]+@[^@]+\.[^@]+', user):
try: try:
v = User.objects.get(email=user) v = User.objects.get(email=user)
except: except:
......
...@@ -14,7 +14,7 @@ from django.test import TestCase ...@@ -14,7 +14,7 @@ from django.test import TestCase
from django.core.management import call_command from django.core.management import call_command
from nose.plugins.skip import SkipTest from nose.plugins.skip import SkipTest
from student.models import User, TestCenterRegistration, TestCenterUser, get_testcenter_registration from student.models import User, TestCenterUser, get_testcenter_registration
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
......
...@@ -55,11 +55,15 @@ class ReactivationEmailTests(EmailTestMixin, TestCase): ...@@ -55,11 +55,15 @@ class ReactivationEmailTests(EmailTestMixin, TestCase):
def setUp(self): def setUp(self):
self.user = UserFactory.create() self.user = UserFactory.create()
self.unregisteredUser = UserFactory.create()
self.registration = RegistrationFactory.create(user=self.user) self.registration = RegistrationFactory.create(user=self.user)
def reactivation_email(self): def reactivation_email(self, user):
"""Send the reactivation email, and return the response as json data""" """
return json.loads(reactivation_email_for_user(self.user).content) Send the reactivation email to the specified user,
and return the response as json data.
"""
return json.loads(reactivation_email_for_user(user).content)
def assertReactivateEmailSent(self, email_user): def assertReactivateEmailSent(self, email_user):
"""Assert that the correct reactivation email has been sent""" """Assert that the correct reactivation email has been sent"""
...@@ -78,13 +82,22 @@ class ReactivationEmailTests(EmailTestMixin, TestCase): ...@@ -78,13 +82,22 @@ class ReactivationEmailTests(EmailTestMixin, TestCase):
def test_reactivation_email_failure(self, email_user): def test_reactivation_email_failure(self, email_user):
self.user.email_user.side_effect = Exception self.user.email_user.side_effect = Exception
response_data = self.reactivation_email() response_data = self.reactivation_email(self.user)
self.assertReactivateEmailSent(email_user) self.assertReactivateEmailSent(email_user)
self.assertFalse(response_data['success']) self.assertFalse(response_data['success'])
def test_reactivation_for_unregistered_user(self, email_user):
"""
Test that trying to send a reactivation email to an unregistered
user fails without throwing a 500 error.
"""
response_data = self.reactivation_email(self.unregisteredUser)
self.assertFalse(response_data['success'])
def test_reactivation_email_success(self, email_user): def test_reactivation_email_success(self, email_user):
response_data = self.reactivation_email() response_data = self.reactivation_email(self.user)
self.assertReactivateEmailSent(email_user) self.assertReactivateEmailSent(email_user)
self.assertTrue(response_data['success']) self.assertTrue(response_data['success'])
...@@ -150,7 +163,7 @@ class EmailChangeRequestTests(TestCase): ...@@ -150,7 +163,7 @@ class EmailChangeRequestTests(TestCase):
self.check_duplicate_email(self.new_email) self.check_duplicate_email(self.new_email)
def test_capitalized_duplicate_email(self): def test_capitalized_duplicate_email(self):
raise SkipTest("We currently don't check for emails in a case insensitive way, but we should") """Test that we check for email addresses in a case insensitive way"""
UserFactory.create(email=self.new_email) UserFactory.create(email=self.new_email)
self.check_duplicate_email(self.new_email.capitalize()) self.check_duplicate_email(self.new_email.capitalize())
......
...@@ -5,18 +5,127 @@ when you run "manage.py test". ...@@ -5,18 +5,127 @@ when you run "manage.py test".
Replace this with more appropriate tests for your application. Replace this with more appropriate tests for your application.
""" """
import logging import logging
import json
import re
import unittest
from django import forms
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from mock import Mock from django.test.client import RequestFactory
from django.contrib.auth.models import User
from django.contrib.auth.hashers import UNUSABLE_PASSWORD
from django.contrib.auth.tokens import default_token_generator
from django.template.loader import render_to_string, get_template, TemplateDoesNotExist
from django.core.urlresolvers import is_valid_path
from django.utils.http import int_to_base36
from student.models import unique_id_for_user
from student.views import process_survey_link, _cert_info
from mock import Mock, patch
from textwrap import dedent
from student.models import unique_id_for_user
from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper
from student.tests.factories import UserFactory
from student.tests.test_email import mock_render_to_string
COURSE_1 = 'edX/toy/2012_Fall' COURSE_1 = 'edX/toy/2012_Fall'
COURSE_2 = 'edx/full/6.002_Spring_2012' COURSE_2 = 'edx/full/6.002_Spring_2012'
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
try:
get_template('registration/password_reset_email.html')
project_uses_password_reset = True
except TemplateDoesNotExist:
project_uses_password_reset = False
class ResetPasswordTests(TestCase):
""" Tests that clicking reset password sends email, and doesn't activate the user
"""
request_factory = RequestFactory()
def setUp(self):
self.user = UserFactory.create()
self.user.is_active = False
self.user.save()
self.token = default_token_generator.make_token(self.user)
self.uidb36 = int_to_base36(self.user.id)
self.user_bad_passwd = UserFactory.create()
self.user_bad_passwd.is_active = False
self.user_bad_passwd.password = UNUSABLE_PASSWORD
self.user_bad_passwd.save()
def test_user_bad_password_reset(self):
"""Tests password reset behavior for user with password marked UNUSABLE_PASSWORD"""
bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email})
bad_pwd_resp = password_reset(bad_pwd_req)
self.assertEquals(bad_pwd_resp.status_code, 200)
self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False,
'error': 'Invalid e-mail or user'}))
def test_nonexist_email_password_reset(self):
"""Now test the exception cases with of reset_password called with invalid email."""
bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"})
bad_email_resp = password_reset(bad_email_req)
self.assertEquals(bad_email_resp.status_code, 200)
self.assertEquals(bad_email_resp.content, json.dumps({'success': False,
'error': 'Invalid e-mail or user'}))
@unittest.skipUnless(project_uses_password_reset,
dedent("""Skipping Test because CMS has not provided necessary templates for password reset.
If LMS tests print this message, that needs to be fixed."""))
@patch('django.core.mail.send_mail')
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_reset_password_email(self, send_email):
"""Tests contents of reset password email, and that user is not active"""
good_req = self.request_factory.post('/password_reset/', {'email': self.user.email})
good_resp = password_reset(good_req)
self.assertEquals(good_resp.status_code, 200)
self.assertEquals(good_resp.content,
json.dumps({'success': True,
'value': "('registration/password_reset_done.html', [])"}))
((subject, msg, from_addr, to_addrs), sm_kwargs) = send_email.call_args
self.assertIn("Password reset", subject)
self.assertIn("You're receiving this e-mail because you requested a password reset", msg)
self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL)
self.assertEquals(len(to_addrs), 1)
self.assertIn(self.user.email, to_addrs)
#test that the user is not active
self.user = User.objects.get(pk=self.user.pk)
self.assertFalse(self.user.is_active)
reset_match = re.search(r'password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/', msg).groupdict()
@patch('student.views.password_reset_confirm')
def test_reset_password_bad_token(self, reset_confirm):
"""Tests bad token and uidb36 in password reset"""
bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/')
password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP')
(confirm_args, confirm_kwargs) = reset_confirm.call_args
self.assertEquals(confirm_kwargs['uidb36'], 'NO')
self.assertEquals(confirm_kwargs['token'], 'OP')
self.user = User.objects.get(pk=self.user.pk)
self.assertFalse(self.user.is_active)
@patch('student.views.password_reset_confirm')
def test_reset_password_good_token(self, reset_confirm):
"""Tests good token and uidb36 in password reset"""
good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token))
password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token)
(confirm_args, confirm_kwargs) = reset_confirm.call_args
self.assertEquals(confirm_kwargs['uidb36'], self.uidb36)
self.assertEquals(confirm_kwargs['token'], self.token)
self.user = User.objects.get(pk=self.user.pk)
self.assertTrue(self.user.is_active)
class CourseEndingTest(TestCase): class CourseEndingTest(TestCase):
"""Test things related to course endings: certificates, surveys, etc""" """Test things related to course endings: certificates, surveys, etc"""
......
...@@ -4,7 +4,6 @@ Browser set up for acceptance tests. ...@@ -4,7 +4,6 @@ Browser set up for acceptance tests.
#pylint: disable=E1101 #pylint: disable=E1101
#pylint: disable=W0613 #pylint: disable=W0613
#pylint: disable=W0611
from lettuce import before, after, world from lettuce import before, after, world
from splinter.browser import Browser from splinter.browser import Browser
...@@ -15,8 +14,9 @@ from selenium.common.exceptions import WebDriverException ...@@ -15,8 +14,9 @@ from selenium.common.exceptions import WebDriverException
# Let the LMS and CMS do their one-time setup # Let the LMS and CMS do their one-time setup
# For example, setting up mongo caches # For example, setting up mongo caches
from lms import one_time_startup # These names aren't used, but do important work on import.
from cms import one_time_startup from lms import one_time_startup # pylint: disable=W0611
from cms import one_time_startup # pylint: disable=W0611
# There is an import issue when using django-staticfiles with lettuce # There is an import issue when using django-staticfiles with lettuce
# Lettuce assumes that we are using django.contrib.staticfiles, # Lettuce assumes that we are using django.contrib.staticfiles,
......
#pylint: disable=C0111 # pylint: disable=C0111
#pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world, step from lettuce import world
from .factories import * from .factories import *
from django.conf import settings from django.conf import settings
from django.http import HttpRequest from django.http import HttpRequest
...@@ -10,12 +10,10 @@ from django.contrib.auth import authenticate, login ...@@ -10,12 +10,10 @@ from django.contrib.auth import authenticate, login
from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.auth.middleware import AuthenticationMiddleware
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
from student.models import CourseEnrollment from student.models import CourseEnrollment
from xmodule.modulestore.django import _MODULESTORES, modulestore from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.templates import update_templates from xmodule.templates import update_templates
from bs4 import BeautifulSoup
import os.path
from urllib import quote_plus from urllib import quote_plus
from lettuce.django import django_url
@world.absorb @world.absorb
...@@ -76,51 +74,6 @@ def register_by_course_id(course_id, is_staff=False): ...@@ -76,51 +74,6 @@ def register_by_course_id(course_id, is_staff=False):
CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) CourseEnrollment.objects.get_or_create(user=u, course_id=course_id)
@world.absorb
def save_the_course_content(path='/tmp'):
html = world.browser.html.encode('ascii', 'ignore')
soup = BeautifulSoup(html)
# get rid of the header, we only want to compare the body
soup.head.decompose()
# for now, remove the data-id attributes, because they are
# causing mismatches between cms-master and master
for item in soup.find_all(attrs={'data-id': re.compile('.*')}):
del item['data-id']
# we also need to remove them from unrendered problems,
# where they are contained in the text of divs instead of
# in attributes of tags
# Be careful of whether or not it was the last attribute
# and needs a trailing space
for item in soup.find_all(text=re.compile(' data-id=".*?" ')):
s = unicode(item.string)
item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s))
for item in soup.find_all(text=re.compile(' data-id=".*?"')):
s = unicode(item.string)
item.string.replace_with(re.sub(' data-id=".*?"', ' ', s))
# prettify the html so it will compare better, with
# each HTML tag on its own line
output = soup.prettify()
# use string slicing to grab everything after 'courseware/' in the URL
u = world.browser.url
section_url = u[u.find('courseware/') + 11:]
if not os.path.exists(path):
os.makedirs(path)
filename = '%s.html' % (quote_plus(section_url))
f = open('%s/%s' % (path, filename), 'w')
f.write(output)
f.close
@world.absorb @world.absorb
def clear_courses(): def clear_courses():
# Flush and initialize the module store # Flush and initialize the module store
...@@ -130,6 +83,6 @@ def clear_courses(): ...@@ -130,6 +83,6 @@ def clear_courses():
# (though it shouldn't), do this manually # (though it shouldn't), do this manually
# from the bash shell to drop it: # from the bash shell to drop it:
# $ mongo test_xmodule --eval "db.dropDatabase()" # $ mongo test_xmodule --eval "db.dropDatabase()"
_MODULESTORES = {}
modulestore().collection.drop() modulestore().collection.drop()
update_templates(modulestore('direct')) update_templates(modulestore('direct'))
contentstore().fs_files.drop()
...@@ -15,13 +15,13 @@ from lettuce import world, step ...@@ -15,13 +15,13 @@ from lettuce import world, step
from .course_helpers import * from .course_helpers import *
from .ui_helpers import * from .ui_helpers import *
from lettuce.django import django_url from lettuce.django import django_url
from nose.tools import assert_equals, assert_in from nose.tools import assert_equals
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
@step(u'I wait (?:for )?"(\d+)" seconds?$') @step(r'I wait (?:for )?"(\d+)" seconds?$')
def wait(step, seconds): def wait(step, seconds):
world.wait(seconds) world.wait(seconds)
......
...@@ -49,7 +49,7 @@ def css_has_text(css_selector, text): ...@@ -49,7 +49,7 @@ def css_has_text(css_selector, text):
@world.absorb @world.absorb
def css_find(css, wait_time=5): def css_find(css, wait_time=5):
def is_visible(driver): def is_visible(_driver):
return EC.visibility_of_element_located((By.CSS_SELECTOR, css,)) return EC.visibility_of_element_located((By.CSS_SELECTOR, css,))
world.browser.is_element_present_by_css(css, wait_time=wait_time) world.browser.is_element_present_by_css(css, wait_time=wait_time)
...@@ -58,19 +58,58 @@ def css_find(css, wait_time=5): ...@@ -58,19 +58,58 @@ def css_find(css, wait_time=5):
@world.absorb @world.absorb
def css_click(css_selector, index=0, attempts=5): def css_click(css_selector, index=0, max_attempts=5, success_condition=lambda: True):
""" """
Perform a click on a CSS selector, retrying if it initially fails Perform a click on a CSS selector, retrying if it initially fails.
This function will return if the click worked (since it is try/excepting all errors)
This function handles errors that may be thrown if the component cannot be clicked on.
However, there are cases where an error may not be thrown, and yet the operation did not
actually succeed. For those cases, a success_condition lambda can be supplied to verify that the click worked.
This function will return True if the click worked (taking into account both errors and the optional
success_condition).
""" """
assert is_css_present(css_selector) assert is_css_present(css_selector)
attempt = 0 attempt = 0
result = False result = False
while attempt < attempts: while attempt < max_attempts:
try: try:
world.css_find(css_selector)[index].click() world.css_find(css_selector)[index].click()
result = True if success_condition():
break result = True
break
except WebDriverException:
# Occasionally, MathJax or other JavaScript can cover up
# an element temporarily.
# If this happens, wait a second, then try again
world.wait(1)
attempt += 1
except:
attempt += 1
return result
@world.absorb
def css_check(css_selector, index=0, max_attempts=5, success_condition=lambda: True):
"""
Checks a check box based on a CSS selector, retrying if it initially fails.
This function handles errors that may be thrown if the component cannot be clicked on.
However, there are cases where an error may not be thrown, and yet the operation did not
actually succeed. For those cases, a success_condition lambda can be supplied to verify that the check worked.
This function will return True if the check worked (taking into account both errors and the optional
success_condition).
"""
assert is_css_present(css_selector)
attempt = 0
result = False
while attempt < max_attempts:
try:
world.css_find(css_selector)[index].check()
if success_condition():
result = True
break
except WebDriverException: except WebDriverException:
# Occasionally, MathJax or other JavaScript can cover up # Occasionally, MathJax or other JavaScript can cover up
# an element temporarily. # an element temporarily.
...@@ -83,15 +122,15 @@ def css_click(css_selector, index=0, attempts=5): ...@@ -83,15 +122,15 @@ def css_click(css_selector, index=0, attempts=5):
@world.absorb @world.absorb
def css_click_at(css, x=10, y=10): def css_click_at(css, x_cord=10, y_cord=10):
''' '''
A method to click at x,y coordinates of the element A method to click at x,y coordinates of the element
rather than in the center of the element rather than in the center of the element
''' '''
e = css_find(css).first element = css_find(css).first
e.action_chains.move_to_element_with_offset(e._element, x, y) element.action_chains.move_to_element_with_offset(element._element, x_cord, y_cord)
e.action_chains.click() element.action_chains.click()
e.action_chains.perform() element.action_chains.perform()
@world.absorb @world.absorb
...@@ -136,7 +175,7 @@ def css_visible(css_selector): ...@@ -136,7 +175,7 @@ def css_visible(css_selector):
@world.absorb @world.absorb
def dialogs_closed(): def dialogs_closed():
def are_dialogs_closed(driver): def are_dialogs_closed(_driver):
''' '''
Return True when no modal dialogs are visible Return True when no modal dialogs are visible
''' '''
...@@ -147,12 +186,12 @@ def dialogs_closed(): ...@@ -147,12 +186,12 @@ def dialogs_closed():
@world.absorb @world.absorb
def save_the_html(path='/tmp'): def save_the_html(path='/tmp'):
u = world.browser.url url = world.browser.url
html = world.browser.html.encode('ascii', 'ignore') html = world.browser.html.encode('ascii', 'ignore')
filename = '%s.html' % quote_plus(u) filename = '%s.html' % quote_plus(url)
f = open('%s/%s' % (path, filename), 'w') file = open('%s/%s' % (path, filename), 'w')
f.write(html) file.write(html)
f.close() file.close()
@world.absorb @world.absorb
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
django admin pages for courseware model django admin pages for courseware model
''' '''
from track.models import * from track.models import TrackingLog
from django.contrib import admin from django.contrib import admin
admin.site.register(TrackingLog) admin.site.register(TrackingLog)
import json import json
from django.conf import settings
import views import views
......
from django.db import models
# Create your models here. # Create your models here.
...@@ -4,7 +4,6 @@ Tests for memcache in util app ...@@ -4,7 +4,6 @@ Tests for memcache in util app
from django.test import TestCase from django.test import TestCase
from django.core.cache import get_cache from django.core.cache import get_cache
from django.conf import settings
from util.memcache import safe_key from util.memcache import safe_key
......
"""Tests for the Zendesk""" """Tests for the Zendesk"""
from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.http import Http404 from django.http import Http404
from django.test import TestCase from django.test import TestCase
......
import datetime
import json import json
import logging import logging
import pprint
import sys import sys
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.core.context_processors import csrf
from django.core.mail import send_mail
from django.core.validators import ValidationError, validate_email from django.core.validators import ValidationError, validate_email
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseServerError from django.http import Http404, HttpResponse, HttpResponseNotAllowed
from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie
from dogapi import dog_stats_api from dogapi import dog_stats_api
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response
from urllib import urlencode
import zendesk import zendesk
import calc import calc
......
import re
import json import json
import logging import logging
import static_replace import static_replace
......
...@@ -93,7 +93,7 @@ def check_variables(string, variables): ...@@ -93,7 +93,7 @@ def check_variables(string, variables):
Pyparsing uses a left-to-right parser, which makes a more Pyparsing uses a left-to-right parser, which makes a more
elegant approach pretty hopeless. elegant approach pretty hopeless.
""" """
general_whitespace = re.compile('[^\\w]+') general_whitespace = re.compile('[^\\w]+') # TODO consider non-ascii
# List of all alnums in string # List of all alnums in string
possible_variables = re.split(general_whitespace, string) possible_variables = re.split(general_whitespace, string)
bad_variables = [] bad_variables = []
......
...@@ -103,8 +103,8 @@ class LoncapaProblem(object): ...@@ -103,8 +103,8 @@ class LoncapaProblem(object):
self.input_state = state.get('input_state', {}) self.input_state = state.get('input_state', {})
# Convert startouttext and endouttext to proper <text></text> # Convert startouttext and endouttext to proper <text></text>
problem_text = re.sub("startouttext\s*/", "text", problem_text) problem_text = re.sub(r"startouttext\s*/", "text", problem_text)
problem_text = re.sub("endouttext\s*/", "/text", problem_text) problem_text = re.sub(r"endouttext\s*/", "/text", problem_text)
self.problem_text = problem_text self.problem_text = problem_text
# parse problem XML file into an element tree # parse problem XML file into an element tree
...@@ -373,7 +373,7 @@ class LoncapaProblem(object): ...@@ -373,7 +373,7 @@ class LoncapaProblem(object):
html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context) html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context)
return html return html
def handle_input_ajax(self, get): def handle_input_ajax(self, data):
''' '''
InputTypes can support specialized AJAX calls. Find the correct input and pass along the correct data InputTypes can support specialized AJAX calls. Find the correct input and pass along the correct data
...@@ -381,10 +381,10 @@ class LoncapaProblem(object): ...@@ -381,10 +381,10 @@ class LoncapaProblem(object):
''' '''
# pull out the id # pull out the id
input_id = get['input_id'] input_id = data['input_id']
if self.inputs[input_id]: if self.inputs[input_id]:
dispatch = get['dispatch'] dispatch = data['dispatch']
return self.inputs[input_id].handle_ajax(dispatch, get) return self.inputs[input_id].handle_ajax(dispatch, data)
else: else:
log.warning("Could not find matching input for id: %s" % input_id) log.warning("Could not find matching input for id: %s" % input_id)
return {} return {}
......
...@@ -10,10 +10,9 @@ import sys ...@@ -10,10 +10,9 @@ import sys
from path import path from path import path
from cStringIO import StringIO from cStringIO import StringIO
from collections import defaultdict
from .calc import UndefinedVariable from calc import UndefinedVariable
from .capa_problem import LoncapaProblem from capa.capa_problem import LoncapaProblem
from mako.lookup import TemplateLookup from mako.lookup import TemplateLookup
logging.basicConfig(format="%(levelname)s %(message)s") logging.basicConfig(format="%(levelname)s %(message)s")
......
...@@ -10,8 +10,6 @@ from .registry import TagRegistry ...@@ -10,8 +10,6 @@ from .registry import TagRegistry
import logging import logging
import re import re
import shlex # for splitting quoted strings
import json
from lxml import etree from lxml import etree
import xml.sax.saxutils as saxutils import xml.sax.saxutils as saxutils
...@@ -28,7 +26,7 @@ class MathRenderer(object): ...@@ -28,7 +26,7 @@ class MathRenderer(object):
tags = ['math'] tags = ['math']
def __init__(self, system, xml): def __init__(self, system, xml):
''' r'''
Render math using latex-like formatting. Render math using latex-like formatting.
Examples: Examples:
...@@ -43,7 +41,7 @@ class MathRenderer(object): ...@@ -43,7 +41,7 @@ class MathRenderer(object):
self.system = system self.system = system
self.xml = xml self.xml = xml
mathstr = re.sub('\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text) mathstr = re.sub(r'\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text)
mtag = 'mathjax' mtag = 'mathjax'
if not r'\displaystyle' in mathstr: if not r'\displaystyle' in mathstr:
mtag += 'inline' mtag += 'inline'
......
...@@ -223,13 +223,13 @@ class InputTypeBase(object): ...@@ -223,13 +223,13 @@ class InputTypeBase(object):
""" """
pass pass
def handle_ajax(self, dispatch, get): def handle_ajax(self, dispatch, data):
""" """
InputTypes that need to handle specialized AJAX should override this. InputTypes that need to handle specialized AJAX should override this.
Input: Input:
dispatch: a string that can be used to determine how to handle the data passed in dispatch: a string that can be used to determine how to handle the data passed in
get: a dictionary containing the data that was sent with the ajax call data: a dictionary containing the data that was sent with the ajax call
Output: Output:
a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. a dictionary object that can be serialized into JSON. This will be sent back to the Javascript.
...@@ -677,20 +677,20 @@ class MatlabInput(CodeInput): ...@@ -677,20 +677,20 @@ class MatlabInput(CodeInput):
self.queue_len = 1 self.queue_len = 1
self.msg = self.plot_submitted_msg self.msg = self.plot_submitted_msg
def handle_ajax(self, dispatch, get): def handle_ajax(self, dispatch, data):
''' '''
Handle AJAX calls directed to this input Handle AJAX calls directed to this input
Args: Args:
- dispatch (str) - indicates how we want this ajax call to be handled - dispatch (str) - indicates how we want this ajax call to be handled
- get (dict) - dictionary of key-value pairs that contain useful data - data (dict) - dictionary of key-value pairs that contain useful data
Returns: Returns:
dict - 'success' - whether or not we successfully queued this submission dict - 'success' - whether or not we successfully queued this submission
- 'message' - message to be rendered in case of error - 'message' - message to be rendered in case of error
''' '''
if dispatch == 'plot': if dispatch == 'plot':
return self._plot_data(get) return self._plot_data(data)
return {} return {}
def ungraded_response(self, queue_msg, queuekey): def ungraded_response(self, queue_msg, queuekey):
...@@ -751,7 +751,7 @@ class MatlabInput(CodeInput): ...@@ -751,7 +751,7 @@ class MatlabInput(CodeInput):
msg = result['msg'] msg = result['msg']
return msg return msg
def _plot_data(self, get): def _plot_data(self, data):
''' '''
AJAX handler for the plot button AJAX handler for the plot button
Args: Args:
...@@ -765,7 +765,7 @@ class MatlabInput(CodeInput): ...@@ -765,7 +765,7 @@ class MatlabInput(CodeInput):
return {'success': False, 'message': 'Cannot connect to the queue'} return {'success': False, 'message': 'Cannot connect to the queue'}
# pull relevant info out of get # pull relevant info out of get
response = get['submission'] response = data['submission']
# construct xqueue headers # construct xqueue headers
qinterface = self.system.xqueue['interface'] qinterface = self.system.xqueue['interface']
...@@ -856,7 +856,7 @@ class ImageInput(InputTypeBase): ...@@ -856,7 +856,7 @@ class ImageInput(InputTypeBase):
""" """
if value is of the form [x,y] then parse it and send along coordinates of previous answer if value is of the form [x,y] then parse it and send along coordinates of previous answer
""" """
m = re.match('\[([0-9]+),([0-9]+)]', m = re.match(r'\[([0-9]+),([0-9]+)]',
self.value.strip().replace(' ', '')) self.value.strip().replace(' ', ''))
if m: if m:
# Note: we subtract 15 to compensate for the size of the dot on the screen. # Note: we subtract 15 to compensate for the size of the dot on the screen.
...@@ -951,16 +951,16 @@ class ChemicalEquationInput(InputTypeBase): ...@@ -951,16 +951,16 @@ class ChemicalEquationInput(InputTypeBase):
""" """
return {'previewer': '/static/js/capa/chemical_equation_preview.js', } return {'previewer': '/static/js/capa/chemical_equation_preview.js', }
def handle_ajax(self, dispatch, get): def handle_ajax(self, dispatch, data):
''' '''
Since we only have chemcalc preview this input, check to see if it Since we only have chemcalc preview this input, check to see if it
matches the corresponding dispatch and send it through if it does matches the corresponding dispatch and send it through if it does
''' '''
if dispatch == 'preview_chemcalc': if dispatch == 'preview_chemcalc':
return self.preview_chemcalc(get) return self.preview_chemcalc(data)
return {} return {}
def preview_chemcalc(self, get): def preview_chemcalc(self, data):
""" """
Render an html preview of a chemical formula or equation. get should Render an html preview of a chemical formula or equation. get should
contain a key 'formula' and value 'some formula string'. contain a key 'formula' and value 'some formula string'.
...@@ -974,7 +974,7 @@ class ChemicalEquationInput(InputTypeBase): ...@@ -974,7 +974,7 @@ class ChemicalEquationInput(InputTypeBase):
result = {'preview': '', result = {'preview': '',
'error': ''} 'error': ''}
formula = get['formula'] formula = data['formula']
if formula is None: if formula is None:
result['error'] = "No formula specified." result['error'] = "No formula specified."
return result return result
......
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment