Commit 20892e1f by Jason Bau

Merge tag 'release-2013-12-09' into edx-west/rc-20131220

Conflicts:
	CHANGELOG.rst
	common/djangoapps/mitxmako/middleware.py
	common/djangoapps/student/models.py
	common/djangoapps/student/tests/factories.py
	common/djangoapps/student/views.py
	common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
	lms/djangoapps/courseware/courses.py
	lms/djangoapps/courseware/tests/test_access.py
	lms/djangoapps/courseware/tests/test_courses.py
	lms/djangoapps/courseware/tests/test_module_render.py
	lms/djangoapps/instructor/tests/test_api.py
	lms/djangoapps/instructor/views/instructor_dashboard.py
	lms/djangoapps/instructor/views/legacy.py
	lms/djangoapps/shoppingcart/models.py
	lms/envs/aws.py
	lms/templates/dashboard/_dashboard_course_listing.html
	lms/templates/main_django.html
	lms/templates/registration/password_reset_complete.html
	lms/urls.py
parents 528d1b03 faa8f16f
...@@ -17,6 +17,7 @@ cms/envs/private.py ...@@ -17,6 +17,7 @@ cms/envs/private.py
/nbproject /nbproject
.idea/ .idea/
.redcar/ .redcar/
codekit-config.json
### OS X artifacts ### OS X artifacts
*.DS_Store *.DS_Store
...@@ -28,6 +29,9 @@ cms/envs/private.py ...@@ -28,6 +29,9 @@ cms/envs/private.py
*.mo *.mo
conf/locale/en/LC_MESSAGES/*.po conf/locale/en/LC_MESSAGES/*.po
!messages.po !messages.po
### Remove when we have real Esperanto translations. For now, ignore
### dummy Esperanto files.
conf/locale/eo/*
### Testing artifacts ### Testing artifacts
.testids/ .testids/
...@@ -45,14 +49,18 @@ reports/ ...@@ -45,14 +49,18 @@ reports/
.prereqs_cache .prereqs_cache
.vagrant/ .vagrant/
node_modules node_modules
.bundle/
bin/
### Static assets pipeline artifacts ### Static assets pipeline artifacts
*.scssc *.scssc
lms/static/css/
lms/static/sass/*.css lms/static/sass/*.css
lms/static/sass/application.scss lms/static/sass/application.scss
lms/static/sass/application-extend1.scss lms/static/sass/application-extend1.scss
lms/static/sass/application-extend2.scss lms/static/sass/application-extend2.scss
lms/static/sass/course.scss lms/static/sass/course.scss
cms/static/css/
cms/static/sass/*.css cms/static/sass/*.css
### Logging artifacts ### Logging artifacts
......
...@@ -92,4 +92,10 @@ Felipe Montoya <felipe.montoya@edunext.co> ...@@ -92,4 +92,10 @@ Felipe Montoya <felipe.montoya@edunext.co>
Julia Hansbrough <julia@edx.org> Julia Hansbrough <julia@edx.org>
Pavel Yushchenko <pavelyushchenko@gmail.com> Pavel Yushchenko <pavelyushchenko@gmail.com>
Nicolas Chevalier <nicolas.chevalier@epitech.eu> Nicolas Chevalier <nicolas.chevalier@epitech.eu>
Gabe Mulley <gabe@edx.org>
Iain Dunning <idunning@mit.edu> Iain Dunning <idunning@mit.edu>
Olivier Marquez <oliviermarquez@gmail.com>
Florian Dufour <neurolit@gmail.com>
Manuel Freire <manuel.freire@fdi.ucm.es>
Daniel Cebrián Robles <danielcebrianr@gmail.com>
Carson Gee <cgee@mit.edu>
...@@ -5,14 +5,107 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,14 +5,107 @@ 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.
Blades: Fix Numerical input to support mathematical operations. BLD-525.
Blades: Improve calculator's tooltip accessibility. Add possibility to navigate
through the hints via arrow keys. BLD-533.
LMS: Add feature for providing background grade report generation via Celery
instructor task, with reports uploaded to S3. Feature is visible on the beta
instructor dashboard. LMS-58
Blades: Added grading support for LTI module. LTI providers can now grade
student's work and send edX scores. OAuth1 based authentication
implemented. BLD-384.
LMS: Beta-tester status is now set on a per-course-run basis, rather than being
valid across all runs with the same course name. Old group membership will
still work across runs, but new beta-testers will only be added to a single
course run.
Blades: Enabled several Video Jasmine tests. BLD-463.
Studio: Continued modification of Studio pages to follow a RESTful framework.
includes Settings pages, edit page for Subsection and Unit, and interfaces
for updating xblocks (xmodules) and getting their editing HTML.
LMS: Improve accessibility of inline discussions in courseware.
Blades: Put 2nd "Hide output" button at top of test box & increase text size for
code response questions. BLD-126.
Blades: Update the calculator hints tooltip with full information. BLD-400.
Blades: Fix transcripts 500 error in studio (BLD-530)
LMS: Add error recovery when a user loads or switches pages in an
inline discussion.
Blades: Allow multiple strings as the correct answer to a string response
question. BLD-474.
Blades: a11y - Videos will alert screenreaders when the video is over.
LMS: Trap focus on the loading element when a user loads more threads
in the forum sidebar to improve accessibility.
LMS: Add error recovery when a user loads more threads in the forum sidebar.
LMS: Add a user-visible alert modal when a forums AJAX request fails.
Blades: Add template for checkboxes response to studio. BLD-193.
Blades: Video player:
- Add spinner;
- Improve initialization of modules;
- Speed up video resizing during page loading;
- Speed up acceptance tests. (BLD-502)
- Fix transcripts bug - when show_captions is set to false. BLD-467.
Studio: change create_item, delete_item, and save_item to RESTful API (STUD-847).
Blades: Fix answer choices rearranging if user tries to stylize something in the
text like with bold or italics. (BLD-449)
LMS: Beta instructor dashboard will only count actively enrolled students for
course enrollment numbers.
Blades: Fix speed menu that is not rendered correctly when YouTube is
unavailable. (BLD-457).
LMS: Users with is_staff=True no longer have the STAFF label appear on
their forum posts.
Blades: Video start and end times now function the same for both YouTube and
HTML5 videos. If end time is set, the video can still play until the end, after
it pauses on the end time.
Blades: Disallow users to enter video url's in http.
LMS: Improve the acessibility of the forum follow post buttons.
Blades: Latex problems are now enabled via use_latex_compiler
key in course settings. (BLD-426)
Blades: Fix bug when the speed can only be changed when the video is playing.
LMS: The dialogs on the wiki "changes" page are now accessible to screen
readers. Now all wiki pages have been made accessible. (LMS-1337)
LMS: Change bulk email implementation to use less memory, and to better handle LMS: Change bulk email implementation to use less memory, and to better handle
duplicate tasks in celery. duplicate tasks in celery.
LMS: When a topic is selected in the forums navigation sidebar, fetch
the thread list using the /threads endpoint of the comments service
instead of /search/threads, which does not sort and paginate
correctly. This requires at least version 31ef160 of
cs_comments_service.
LMS: Improve forum error handling so that errors in the logs are LMS: Improve forum error handling so that errors in the logs are
clearer and HTTP status codes from the comments service indicating clearer and HTTP status codes from the comments service indicating
client error are correctly passed through to the client. client error are correctly passed through to the client.
LMS: Improve performance of page load and thread list load for LMS: Improve performance of page load and thread list load for
discussion tab discussion tab
Studio: Support targeted feedback, which allows for authors to provide explanations for Studio: Support targeted feedback, which allows for authors to provide explanations for
...@@ -36,7 +129,7 @@ LMS: The wiki markup cheatsheet dialog is now accessible to screen readers. ...@@ -36,7 +129,7 @@ LMS: The wiki markup cheatsheet dialog is now accessible to screen readers.
Common: Add skip links for accessibility to CMS and LMS. (LMS-1311) Common: Add skip links for accessibility to CMS and LMS. (LMS-1311)
Studio: Change course overview page, checklists, assets, and course staff Studio: Change course overview page, checklists, assets, import, export, and course staff
management page URLs to a RESTful interface. Also removed "\listing", which management page URLs to a RESTful interface. Also removed "\listing", which
duplicated "\index". duplicated "\index".
...@@ -58,17 +151,38 @@ Blades: When start time and end time are specified for a video, a visual range ...@@ -58,17 +151,38 @@ Blades: When start time and end time are specified for a video, a visual range
will be shown on the time slider to highlight the place in the video that will will be shown on the time slider to highlight the place in the video that will
be played. be played.
Studio: added restful interface for finding orphans in courses. Studio: added restful interface for finding orphans in courses.
An orphan is an xblock to which no children relation points and whose type is not An orphan is an xblock to which no children relation points and whose type is not
in the set contentstore.views.item.DETACHED_CATEGORIES nor 'course'. in the set contentstore.views.item.DETACHED_CATEGORIES nor 'course'.
GET http://host/orphan/org.course returns json array of ids. GET http://host/orphan/org.course returns json array of ids.
Requires course author access. Requires course author access.
DELETE http://orphan/org.course deletes all the orphans in that course. DELETE http://orphan/org.course deletes all the orphans in that course.
Requires is_staff access Requires is_staff access
Studio: Bug fix for text loss in Course Updates when the text exists Studio: Bug fix for text loss in Course Updates when the text exists
before the first tag. before the first tag.
Common: expect_json decorator now puts the parsed json payload into a json attr
on the request instead of overwriting the POST attr
---------- split mongo backend refactoring changelog section ------------
Studio: course catalog, assets, checklists, course outline pages now use course
id syntax w/ restful api style
Common:
separate the non-sql db connection configuration from the modulestore (xblock modeling) configuration.
in split, separate the the db connection and atomic crud ops into a distinct module & class from modulestore
Common: location mapper: % encode periods and dollar signs when used as key in the mapping dict
Common: location mapper: added a bunch of new helper functions for generating
old location style info from a CourseLocator
Common: locators: allow - ~ and . in course, branch, and block ids.
---------- end split mongo backend section ---------
Blades: Hovering over CC button in video player, when transcripts are hidden, Blades: Hovering over CC button in video player, when transcripts are hidden,
will cause them to show up. Moving the mouse from the CC button will auto hide will cause them to show up. Moving the mouse from the CC button will auto hide
them. You can hover over the CC button and then move the mouse to the them. You can hover over the CC button and then move the mouse to the
...@@ -424,22 +538,6 @@ Studio: Add feedback to end user if there is a problem exporting a course ...@@ -424,22 +538,6 @@ Studio: Add feedback to end user if there is a problem exporting a course
Studio: Improve link re-writing on imports into a different course-id Studio: Improve link re-writing on imports into a different course-id
---------- split mongo backend refactoring changelog section ------------
Studio: course catalog and course outline pages new use course id syntax w/ restful api style
Common:
separate the non-sql db connection configuration from the modulestore (xblock modeling) configuration.
in split, separate the the db connection and atomic crud ops into a distinct module & class from modulestore
Common: location mapper: % encode periods and dollar signs when used as key in the mapping dict
Common: location mapper: added a bunch of new helper functions for generating old location style info from a CourseLocator
Common: locators: allow - ~ and . in course, branch, and block ids.
---------- end split mongo backend section ---------
XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is
dropped suddenly. dropped suddenly.
......
"""
Studio authorization functions primarily for course creators, instructors, and staff
"""
#======================================================================================================================= #=======================================================================================================================
# #
# This code is somewhat duplicative of access.py in the LMS. We will unify the code as a separate story # This code is somewhat duplicative of access.py in the LMS. We will unify the code as a separate story
...@@ -11,6 +14,8 @@ from django.conf import settings ...@@ -11,6 +14,8 @@ from django.conf import settings
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.locator import CourseLocator, Locator from xmodule.modulestore.locator import CourseLocator, Locator
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError
import itertools
# define a couple of simple roles, we just need ADMIN and EDITOR now for our purposes # define a couple of simple roles, we just need ADMIN and EDITOR now for our purposes
...@@ -25,31 +30,62 @@ COURSE_CREATOR_GROUP_NAME = "course_creator_group" ...@@ -25,31 +30,62 @@ COURSE_CREATOR_GROUP_NAME = "course_creator_group"
# of those two variables # of those two variables
def get_course_groupname_for_role(location, role): def get_all_course_role_groupnames(location, role, use_filter=True):
'''
Get all of the possible groupnames for this role location pair. If use_filter==True,
only return the ones defined in the groups collection.
'''
location = Locator.to_locator_or_location(location) location = Locator.to_locator_or_location(location)
# hack: check for existence of a group name in the legacy LMS format <role>_<course> # hack: check for existence of a group name in the legacy LMS format <role>_<course>
# if it exists, then use that one, otherwise use a <role>_<course_id> which contains # if it exists, then use that one, otherwise use a <role>_<course_id> which contains
# more information # more information
groupnames = [] groupnames = []
groupnames.append('{0}_{1}'.format(role, location.course_id)) try:
groupnames.append('{0}_{1}'.format(role, location.course_id))
except InvalidLocationError: # will occur on old locations where location is not of category course
pass
if isinstance(location, Location): if isinstance(location, Location):
# least preferred role_course format
groupnames.append('{0}_{1}'.format(role, location.course)) groupnames.append('{0}_{1}'.format(role, location.course))
try:
locator = loc_mapper().translate_location(location.course_id, location, False, False)
groupnames.append('{0}_{1}'.format(role, locator.course_id))
except (InvalidLocationError, ItemNotFoundError):
pass
elif isinstance(location, CourseLocator): elif isinstance(location, CourseLocator):
old_location = loc_mapper().translate_locator_to_location(location) old_location = loc_mapper().translate_locator_to_location(location, get_course=True)
if old_location: if old_location:
# the slashified version of the course_id (myu/mycourse/myrun)
groupnames.append('{0}_{1}'.format(role, old_location.course_id)) groupnames.append('{0}_{1}'.format(role, old_location.course_id))
# add the least desirable but sometimes occurring format.
for groupname in groupnames: groupnames.append('{0}_{1}'.format(role, old_location.course))
if Group.objects.filter(name=groupname).exists(): # filter to the ones which exist
return groupname default = groupnames[0]
return groupnames[0] if use_filter:
groupnames = [group for group in groupnames if Group.objects.filter(name=group).exists()]
return groupnames, default
def get_users_in_course_group_by_role(location, role): def get_course_groupname_for_role(location, role):
groupname = get_course_groupname_for_role(location, role) '''
(group, _created) = Group.objects.get_or_create(name=groupname) Get the preferred used groupname for this role, location combo.
return group.user_set.all() Preference order:
* role_course_id (e.g., staff_myu.mycourse.myrun)
* role_old_course_id (e.g., staff_myu/mycourse/myrun)
* role_old_course (e.g., staff_mycourse)
'''
groupnames, default = get_all_course_role_groupnames(location, role)
return groupnames[0] if groupnames else default
def get_course_role_users(course_locator, role):
'''
Get all of the users with the given role in the given course.
'''
groupnames, _ = get_all_course_role_groupnames(course_locator, role)
groups = [Group.objects.get(name=groupname) for groupname in groupnames]
return list(itertools.chain.from_iterable(group.user_set.all() for group in groups))
def create_all_course_groups(creator, location): def create_all_course_groups(creator, location):
...@@ -61,11 +97,11 @@ def create_all_course_groups(creator, location): ...@@ -61,11 +97,11 @@ def create_all_course_groups(creator, location):
def create_new_course_group(creator, location, role): def create_new_course_group(creator, location, role):
groupname = get_course_groupname_for_role(location, role) '''
(group, created) = Group.objects.get_or_create(name=groupname) Create the new course group always using the preferred name even if another form already exists.
if created: '''
group.save() groupnames, __ = get_all_course_role_groupnames(location, role, use_filter=False)
group, __ = Group.objects.get_or_create(name=groupnames[0])
creator.groups.add(group) creator.groups.add(group)
creator.save() creator.save()
...@@ -78,15 +114,13 @@ def _delete_course_group(location): ...@@ -78,15 +114,13 @@ def _delete_course_group(location):
asserted permissions asserted permissions
""" """
# remove all memberships # remove all memberships
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
for user in instructors.user_set.all(): groupnames, _ = get_all_course_role_groupnames(location, role)
user.groups.remove(instructors) for groupname in groupnames:
user.save() group = Group.objects.get(name=groupname)
for user in group.user_set.all():
staff = Group.objects.get(name=get_course_groupname_for_role(location, STAFF_ROLE_NAME)) user.groups.remove(group)
for user in staff.user_set.all(): user.save()
user.groups.remove(staff)
user.save()
def _copy_course_group(source, dest): def _copy_course_group(source, dest):
...@@ -94,25 +128,25 @@ def _copy_course_group(source, dest): ...@@ -94,25 +128,25 @@ 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 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 asserted permissions to do this action
""" """
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) groupnames, _ = get_all_course_role_groupnames(source, role)
for user in instructors.user_set.all(): for groupname in groupnames:
user.groups.add(new_instructors_group) group = Group.objects.get(name=groupname)
user.save() new_group, _ = Group.objects.get_or_create(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
for user in group.user_set.all():
staff = Group.objects.get(name=get_course_groupname_for_role(source, STAFF_ROLE_NAME)) user.groups.add(new_group)
new_staff_group = Group.objects.get(name=get_course_groupname_for_role(dest, STAFF_ROLE_NAME)) user.save()
for user in staff.user_set.all():
user.groups.add(new_staff_group)
user.save()
def add_user_to_course_group(caller, user, location, role): def add_user_to_course_group(caller, user, location, role):
"""
If caller is authorized, add the given user to the given course's role
"""
# only admins can add/remove other users # only admins can add/remove other users
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
group = Group.objects.get(name=get_course_groupname_for_role(location, role)) group, _ = Group.objects.get_or_create(name=get_course_groupname_for_role(location, role))
return _add_user_to_group(user, group) return _add_user_to_group(user, group)
...@@ -128,9 +162,7 @@ def add_user_to_creator_group(caller, user): ...@@ -128,9 +162,7 @@ def add_user_to_creator_group(caller, user):
if not caller.is_active or not caller.is_authenticated or not caller.is_staff: if not caller.is_active or not caller.is_authenticated or not caller.is_staff:
raise PermissionDenied raise PermissionDenied
(group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME) (group, _) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME)
if created:
group.save()
return _add_user_to_group(user, group) return _add_user_to_group(user, group)
...@@ -148,6 +180,9 @@ def _add_user_to_group(user, group): ...@@ -148,6 +180,9 @@ def _add_user_to_group(user, group):
def get_user_by_email(email): def get_user_by_email(email):
"""
Get the user whose email is the arg. Return None if no such user exists.
"""
user = None user = None
# try to look up user, return None if not found # try to look up user, return None if not found
try: try:
...@@ -159,13 +194,21 @@ def get_user_by_email(email): ...@@ -159,13 +194,21 @@ def get_user_by_email(email):
def remove_user_from_course_group(caller, user, location, role): def remove_user_from_course_group(caller, user, location, role):
"""
If caller is authorized, remove the given course x role authorization for user
"""
# only admins can add/remove other users # only admins can add/remove other users
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
# 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): groupnames, _ = get_all_course_role_groupnames(location, role)
_remove_user_from_group(user, get_course_groupname_for_role(location, role)) for groupname in groupnames:
groups = user.groups.filter(name=groupname)
if groups:
# will only be one with that name
user.groups.remove(groups[0])
user.save()
def remove_user_from_creator_group(caller, user): def remove_user_from_creator_group(caller, user):
...@@ -191,11 +234,16 @@ def _remove_user_from_group(user, group_name): ...@@ -191,11 +234,16 @@ def _remove_user_from_group(user, group_name):
def is_user_in_course_group_role(user, location, role, check_staff=True): def is_user_in_course_group_role(user, location, role, check_staff=True):
"""
Check whether the given user has the given role in this course. If check_staff
then give permission if the user is staff without doing a course-role query.
"""
if user.is_active and user.is_authenticated: if user.is_active and user.is_authenticated:
# all "is_staff" flagged accounts belong to all groups # all "is_staff" flagged accounts belong to all groups
if check_staff and user.is_staff: if check_staff and user.is_staff:
return True return True
return user.groups.filter(name=get_course_groupname_for_role(location, role)).exists() groupnames, _ = get_all_course_role_groupnames(location, role)
return any(user.groups.filter(name=groupname).exists() for groupname in groupnames)
return False return False
......
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore
from lxml import html, etree
import re import re
from django.http import HttpResponseBadRequest
import logging import logging
from lxml import html, etree
from django.http import HttpResponseBadRequest
import django.utils import django.utils
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore
# # TODO store as array of { date, content } and override course_info_module.definition_from_xml # # TODO store as array of { date, content } and override course_info_module.definition_from_xml
# # This should be in a class which inherits from XmlDescriptor # # This should be in a class which inherits from XmlDescriptor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def get_course_updates(location): def get_course_updates(location, provided_id):
""" """
Retrieve the relevant course_info updates and unpack into the model which the client expects: Retrieve the relevant course_info updates and unpack into the model which the client expects:
[{id : location.url() + idx to make unique, date : string, content : html string}] [{id : index, date : string, content : html string}]
""" """
try: try:
course_updates = modulestore('direct').get_item(location) course_updates = modulestore('direct').get_item(location)
...@@ -35,15 +35,23 @@ def get_course_updates(location): ...@@ -35,15 +35,23 @@ def get_course_updates(location):
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val # Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
course_upd_collection = [] course_upd_collection = []
provided_id = get_idx(provided_id) if provided_id is not None else None
if course_html_parsed.tag == 'ol': if course_html_parsed.tag == 'ol':
# 0 is the newest # 0 is the newest
for idx, update in enumerate(course_html_parsed): for idx, update in enumerate(course_html_parsed):
if len(update) > 0: if len(update) > 0:
content = _course_info_content(update) content = _course_info_content(update)
# make the id on the client be 1..len w/ 1 being the oldest and len being the newest # make the id on the client be 1..len w/ 1 being the oldest and len being the newest
course_upd_collection.append({"id": location_base + "/" + str(len(course_html_parsed) - idx), computed_id = len(course_html_parsed) - idx
"date": update.findtext("h2"), payload = {
"content": content}) "id": computed_id,
"date": update.findtext("h2"),
"content": content
}
if provided_id is None:
course_upd_collection.append(payload)
elif provided_id == computed_id:
return payload
return course_upd_collection return course_upd_collection
...@@ -57,7 +65,8 @@ def update_course_updates(location, update, passed_id=None): ...@@ -57,7 +65,8 @@ def update_course_updates(location, update, passed_id=None):
try: try:
course_updates = modulestore('direct').get_item(location) course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError: except ItemNotFoundError:
return HttpResponseBadRequest() modulestore('direct').create_and_save_xmodule(location)
course_updates = modulestore('direct').get_item(location)
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try: try:
...@@ -89,17 +98,17 @@ def update_course_updates(location, update, passed_id=None): ...@@ -89,17 +98,17 @@ def update_course_updates(location, update, passed_id=None):
course_html_parsed[-idx] = new_html_parsed course_html_parsed[-idx] = new_html_parsed
else: else:
course_html_parsed.insert(0, new_html_parsed) course_html_parsed.insert(0, new_html_parsed)
idx = len(course_html_parsed) idx = len(course_html_parsed)
passed_id = course_updates.location.url() + "/" + str(idx)
# update db record # update db record
course_updates.data = html.tostring(course_html_parsed) course_updates.data = html.tostring(course_html_parsed)
modulestore('direct').update_item(location, course_updates.data) modulestore('direct').update_item(location, course_updates.data)
return {"id": passed_id, return {
"date": update['date'], "id": idx,
"content": _course_info_content(new_html_parsed)} "date": update['date'],
"content": _course_info_content(new_html_parsed),
}
def _course_info_content(html_parsed): def _course_info_content(html_parsed):
...@@ -115,6 +124,7 @@ def _course_info_content(html_parsed): ...@@ -115,6 +124,7 @@ def _course_info_content(html_parsed):
return content return content
# pylint: disable=unused-argument
def delete_course_update(location, update, passed_id): def delete_course_update(location, update, passed_id):
""" """
Delete the given course_info update from the db. Delete the given course_info update from the db.
...@@ -150,7 +160,7 @@ def delete_course_update(location, update, passed_id): ...@@ -150,7 +160,7 @@ def delete_course_update(location, update, passed_id):
store = modulestore('direct') store = modulestore('direct')
store.update_item(location, course_updates.data) store.update_item(location, course_updates.data)
return get_course_updates(location) return get_course_updates(location, None)
def get_idx(passed_id): def get_idx(passed_id):
...@@ -160,3 +170,5 @@ def get_idx(passed_id): ...@@ -160,3 +170,5 @@ def get_idx(passed_id):
idx_matcher = re.search(r'.*?/?(\d+)$', passed_id) idx_matcher = re.search(r'.*?/?(\d+)$', passed_id)
if idx_matcher: if idx_matcher:
return int(idx_matcher.group(1)) return int(idx_matcher.group(1))
else:
return None
...@@ -13,14 +13,20 @@ DISPLAY_NAME_VALUE = '"Robot Super Course"' ...@@ -13,14 +13,20 @@ DISPLAY_NAME_VALUE = '"Robot Super Course"'
@step('I select the Advanced Settings$') @step('I select the Advanced Settings$')
def i_select_advanced_settings(step): def i_select_advanced_settings(step):
world.click_course_settings() world.click_course_settings()
# The click handlers are set up so that if you click <body>
# the menu disappears. This means that if we're even a *little*
# bit off on the last item ('Advanced Settings'), the menu
# will close and the test will fail.
# For this reason, we retrieve the link and visit it directly
# This is what the browser *should* be doing, since it's just a native
# link with no JavaScript involved.
link_css = 'li.nav-course-settings-advanced a' link_css = 'li.nav-course-settings-advanced a'
world.css_click(link_css) world.wait_for_visible(link_css)
world.wait_for_requirejs( link = world.css_find(link_css).first['href']
["jquery", "js/models/course", "js/models/settings/advanced", world.visit(link)
"js/views/settings/advanced", "codemirror"])
# this shouldn't be necessary, but we experience sporadic failures otherwise
world.wait(1)
@step('I am on the Advanced Course Settings page in Studio$') @step('I am on the Advanced Course Settings page in Studio$')
......
...@@ -385,3 +385,18 @@ def create_other_user(_step, name, has_extra_perms, role_name): ...@@ -385,3 +385,18 @@ def create_other_user(_step, name, has_extra_perms, role_name):
@step('I log out') @step('I log out')
def log_out(_step): def log_out(_step):
world.visit('logout') world.visit('logout')
@step(u'I click on "edit a draft"$')
def i_edit_a_draft(_step):
world.css_click("a.create-draft")
@step(u'I click on "replace with draft"$')
def i_edit_a_draft(_step):
world.css_click("a.publish-draft")
@step(u'I publish the unit$')
def publish_unit(_step):
world.select_option('visibility-select', 'public')
...@@ -19,11 +19,19 @@ Feature: CMS.Component Adding ...@@ -19,11 +19,19 @@ Feature: CMS.Component Adding
| Component | | Component |
| Text | | Text |
| Announcement | | Announcement |
| E-text Written in LaTeX |
Then I see HTML components in this order: Then I see HTML components in this order:
| Component | | Component |
| Text | | Text |
| Announcement | | Announcement |
Scenario: I can add Latex HTML components
Given I am in Studio editing a new unit
Given I have enabled latex compiler
When I add this type of HTML component:
| Component |
| E-text Written in LaTeX |
Then I see HTML components in this order:
| Component |
| E-text Written in LaTeX | | E-text Written in LaTeX |
Scenario: I can add Common Problem components Scenario: I can add Common Problem components
...@@ -31,6 +39,7 @@ Feature: CMS.Component Adding ...@@ -31,6 +39,7 @@ Feature: CMS.Component Adding
When I add this type of Problem component: When I add this type of Problem component:
| Component | | Component |
| Blank Common Problem | | Blank Common Problem |
| Checkboxes |
| Dropdown | | Dropdown |
| Multiple Choice | | Multiple Choice |
| Numerical Input | | Numerical Input |
...@@ -38,14 +47,20 @@ Feature: CMS.Component Adding ...@@ -38,14 +47,20 @@ Feature: CMS.Component Adding
Then I see Problem components in this order: Then I see Problem components in this order:
| Component | | Component |
| Blank Common Problem | | Blank Common Problem |
| Checkboxes |
| Dropdown | | Dropdown |
| Multiple Choice | | Multiple Choice |
| Numerical Input | | Numerical Input |
| Text Input | | Text Input |
Scenario: I can add Advanced Problem components Scenario Outline: I can add Advanced Problem components
Given I am in Studio editing a new unit Given I am in Studio editing a new unit
When I add this type of Advanced Problem component: When I add a "<Component>" "Advanced Problem" component
Then I see a "<Component>" Problem component
# Flush out the database before the next example executes
And I reset the database
Examples:
| Component | | Component |
| Blank Advanced Problem | | Blank Advanced Problem |
| Circuit Schematic Builder | | Circuit Schematic Builder |
...@@ -53,18 +68,21 @@ Feature: CMS.Component Adding ...@@ -53,18 +68,21 @@ Feature: CMS.Component Adding
| Drag and Drop | | Drag and Drop |
| Image Mapped Input | | Image Mapped Input |
| Math Expression Input | | Math Expression Input |
| Problem Written in LaTeX |
| Problem with Adaptive Hint | | Problem with Adaptive Hint |
Then I see Problem components in this order:
Scenario: I can add Advanced Latex Problem components
Given I am in Studio editing a new unit
Given I have enabled latex compiler
When I add a "<Component>" "Advanced Problem" component
Then I see a "<Component>" Problem component
# Flush out the database before the next example executes
And I reset the database
Examples:
| Component | | Component |
| Blank Advanced Problem |
| Circuit Schematic Builder |
| Custom Python-Evaluated Input |
| Drag and Drop |
| Image Mapped Input |
| Math Expression Input |
| Problem Written in LaTeX | | Problem Written in LaTeX |
| Problem with Adaptive Hint | | Problem with Adaptive Hint in Latex |
Scenario: I see a prompt on delete Scenario: I see a prompt on delete
Given I am in Studio editing a new unit Given I am in Studio editing a new unit
......
#pylint: disable=C0111 #pylint: disable=C0111
#pylint: disable=W0621 #pylint: disable=W0621
# Lettuce formats proposed definitions for unimplemented steps with the
# argument name "step" instead of "_step" and pylint does not like that.
#pylint: disable=W0613
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_true, assert_in # pylint: disable=E0611 from nose.tools import assert_true, assert_in # pylint: disable=E0611
...@@ -63,6 +67,17 @@ def see_a_multi_step_component(step, category): ...@@ -63,6 +67,17 @@ def see_a_multi_step_component(step, category):
assert_in(step_hash['Component'].upper(), actual_text) assert_in(step_hash['Component'].upper(), actual_text)
@step(u'I see a "([^"]*)" Problem component$')
def see_a_problem_component(step, category):
component_css = 'section.xmodule_CapaModule'
assert_true(world.is_css_present(component_css),
'No problem was added to the unit.')
problem_css = 'li.component section.xblock-student_view'
actual_text = world.css_text(problem_css)
assert_in(category.upper(), actual_text)
@step(u'I add a "([^"]*)" "([^"]*)" component$') @step(u'I add a "([^"]*)" "([^"]*)" component$')
def add_component_category(step, component, category): def add_component_category(step, component, category):
assert category in ('single step', 'HTML', 'Problem', 'Advanced Problem') assert category in ('single step', 'HTML', 'Problem', 'Advanced Problem')
...@@ -72,13 +87,18 @@ def add_component_category(step, component, category): ...@@ -72,13 +87,18 @@ def add_component_category(step, component, category):
@step(u'I delete all components$') @step(u'I delete all components$')
def delete_all_components(step): def delete_all_components(step):
count = len(world.css_find('ol.components li.component'))
step.given('I delete "' + str(count) + '" component')
@step(u'I delete "([^"]*)" component$')
def delete_components(step, number):
world.wait_for_xmodule() world.wait_for_xmodule()
delete_btn_css = 'a.delete-button' delete_btn_css = 'a.delete-button'
prompt_css = 'div#prompt-warning' prompt_css = 'div#prompt-warning'
btn_css = '{} a.button.action-primary'.format(prompt_css) btn_css = '{} a.button.action-primary'.format(prompt_css)
saving_mini_css = 'div#page-notification .wrapper-notification-mini' saving_mini_css = 'div#page-notification .wrapper-notification-mini'
count = len(world.css_find('ol.components li.component')) for _ in range(int(number)):
for _ in range(int(count)):
world.css_click(delete_btn_css) world.css_click(delete_btn_css)
assert_true( assert_true(
world.is_css_present('{}.is-shown'.format(prompt_css)), world.is_css_present('{}.is-shown'.format(prompt_css)),
......
...@@ -6,14 +6,6 @@ from nose.tools import assert_equal, assert_in # pylint: disable=E0611 ...@@ -6,14 +6,6 @@ from nose.tools import assert_equal, assert_in # pylint: disable=E0611
from terrain.steps import reload_the_page from terrain.steps import reload_the_page
def _is_expected_element_count(css, expected_number):
"""
Returns whether the number of elements found on the page by css locator
the same number that you expected.
"""
return len(world.css_find(css)) == expected_number
@world.absorb @world.absorb
def create_component_instance(step, category, component_type=None, is_advanced=False): def create_component_instance(step, category, component_type=None, is_advanced=False):
""" """
...@@ -47,8 +39,11 @@ def create_component_instance(step, category, component_type=None, is_advanced=F ...@@ -47,8 +39,11 @@ def create_component_instance(step, category, component_type=None, is_advanced=F
world.wait_for_invisible(component_button_css) world.wait_for_invisible(component_button_css)
click_component_from_menu(category, component_type, is_advanced) click_component_from_menu(category, component_type, is_advanced)
world.wait_for(lambda _: _is_expected_element_count(module_css, expected_count = module_count_before + 1
module_count_before + 1)) world.wait_for(
lambda _: len(world.css_find(module_css)) == expected_count,
timeout=20
)
@world.absorb @world.absorb
......
...@@ -179,7 +179,9 @@ def verify_date_or_time(css, date_or_time): ...@@ -179,7 +179,9 @@ def verify_date_or_time(css, date_or_time):
""" """
Verifies date or time field. Verifies date or time field.
""" """
assert_equal(date_or_time, world.css_value(css)) # We need to wait for JavaScript to fill in the field, so we use
# css_has_value(), which first checks that the field is not blank
assert_true(world.css_has_value(css, date_or_time))
@step('I do not see the changes') @step('I do not see the changes')
......
...@@ -76,3 +76,17 @@ Feature: CMS.Course updates ...@@ -76,3 +76,17 @@ Feature: CMS.Course updates
Then I see the handout "/c4x/MITx/999/asset/modified.jpg" Then I see the handout "/c4x/MITx/999/asset/modified.jpg"
And when I reload the page And when I reload the page
Then I see the handout "/c4x/MITx/999/asset/modified.jpg" Then I see the handout "/c4x/MITx/999/asset/modified.jpg"
Scenario: Users cannot save handouts with bad html until edit or update it properly
Given I have opened a new course in Studio
And I go to the course updates page
When I modify the handout to "<p><a href=>[LINK TEXT]</a></p>"
Then I see the handout error text
And I see handout save button disabled
When I edit the handout to "<p><a href='https://www.google.com.pk/'>home</a></p>"
Then I see handout save button re-enabled
When I save handout edit
# Can only do partial text matches because of the quotes with in quotes (and regexp step matching).
Then I see the handout "https://www.google.com.pk/"
And when I reload the page
Then I see the handout "https://www.google.com.pk/"
...@@ -90,6 +90,35 @@ def check_handout(_step, handout): ...@@ -90,6 +90,35 @@ def check_handout(_step, handout):
assert handout in world.css_html(handout_css) assert handout in world.css_html(handout_css)
@step(u'I see the handout error text')
def check_handout_error(_step):
handout_error_css = 'div#handout_error'
assert world.css_has_class(handout_error_css, 'is-shown')
@step(u'I see handout save button disabled')
def check_handout_error(_step):
handout_save_button = 'form.edit-handouts-form a.save-button'
assert world.css_has_class(handout_save_button, 'is-disabled')
@step(u'I edit the handout to "([^"]*)"$')
def edit_handouts(_step, text):
type_in_codemirror(0, text)
@step(u'I see handout save button re-enabled')
def check_handout_error(_step):
handout_save_button = 'form.edit-handouts-form a.save-button'
assert not world.css_has_class(handout_save_button, 'is-disabled')
@step(u'I save handout edit')
def check_handout_error(_step):
save_css = 'a.save-button'
world.css_click(save_css)
def change_text(text): def change_text(text):
type_in_codemirror(0, text) type_in_codemirror(0, text)
save_css = 'a.save-button' save_css = 'a.save-button'
......
...@@ -22,6 +22,7 @@ def i_see_only_the_html_display_name(step): ...@@ -22,6 +22,7 @@ def i_see_only_the_html_display_name(step):
@step('I have created an E-text Written in LaTeX$') @step('I have created an E-text Written in LaTeX$')
def i_created_etext_in_latex(step): def i_created_etext_in_latex(step):
world.create_course_with_unit() world.create_course_with_unit()
step.given('I have enabled latex compiler')
world.create_component_instance( world.create_component_instance(
step=step, step=step,
category='html', category='html',
......
...@@ -81,14 +81,34 @@ Feature: CMS.Problem Editor ...@@ -81,14 +81,34 @@ Feature: CMS.Problem Editor
When I edit and select Settings When I edit and select Settings
Then Edit High Level Source is visible Then Edit High Level Source is visible
# This is a very specific scenario that was failing with some of the
# DB rearchitecture changes. It had to do with children IDs being stored
# with @draft at the end. To reproduce, must update children while in draft mode.
Scenario: Problems can be deleted after being public
Given I have created a Blank Common Problem
And I have created another Blank Common Problem
When I publish the unit
And I click on "edit a draft"
And I delete "1" component
And I click on "replace with draft"
And I click on "edit a draft"
And I delete "1" component
Then I see no components
# Disabled 11/13/2013 after failing in master
# The screenshot showed that the LaTeX editor had the text "hi",
# but Selenium timed out waiting for the text to appear.
# It also caused later tests to fail with "UnexpectedAlertPresent"
#
# This feature will work in Firefox only when Firefox is the active window # This feature will work in Firefox only when Firefox is the active window
# IE will not interact with the high level source in sauce labs # IE will not interact with the high level source in sauce labs
@skip_internetexplorer #@skip_internetexplorer
Scenario: High Level source is persisted for LaTeX problem (bug STUD-280) #Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
Given I have created a LaTeX Problem # Given I have created a LaTeX Problem
When I edit and compile the High Level Source # When I edit and compile the High Level Source
Then my change to the High Level Source is persisted # Then my change to the High Level Source is persisted
And when I view the High Level Source I see my changes # And when I view the High Level Source I see my changes
# Disabled 10/28/13 due to flakiness observed in master # Disabled 10/28/13 due to flakiness observed in master
# Scenario: Exceptions don't cause problem to be uneditable (bug STUD-786) # Scenario: Exceptions don't cause problem to be uneditable (bug STUD-786)
......
...@@ -5,6 +5,7 @@ import json ...@@ -5,6 +5,7 @@ import json
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_equal, assert_true # pylint: disable=E0611 from nose.tools import assert_equal, assert_true # pylint: disable=E0611
from common import type_in_codemirror, open_new_course from common import type_in_codemirror, open_new_course
from advanced_settings import change_value
from course_import import import_file, go_to_import from course_import import import_file, go_to_import
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
...@@ -18,6 +19,11 @@ TIMER_BETWEEN_ATTEMPTS = "Timer Between Attempts" ...@@ -18,6 +19,11 @@ TIMER_BETWEEN_ATTEMPTS = "Timer Between Attempts"
@step('I have created a Blank Common Problem$') @step('I have created a Blank Common Problem$')
def i_created_blank_common_problem(step): def i_created_blank_common_problem(step):
world.create_course_with_unit() world.create_course_with_unit()
step.given("I have created another Blank Common Problem")
@step('I have created another Blank Common Problem$')
def i_create_new_common_problem(step):
world.create_component_instance( world.create_component_instance(
step=step, step=step,
category='problem', category='problem',
...@@ -160,9 +166,19 @@ def cancel_does_not_save_changes(step): ...@@ -160,9 +166,19 @@ def cancel_does_not_save_changes(step):
step.given("I see the advanced settings and their expected values") step.given("I see the advanced settings and their expected values")
@step('I have enabled latex compiler')
def enable_latex_compiler(step):
url = world.browser.url
step.given("I select the Advanced Settings")
change_value(step, 'use_latex_compiler', True)
world.visit(url)
world.wait_for_xmodule()
@step('I have created a LaTeX Problem') @step('I have created a LaTeX Problem')
def create_latex_problem(step): def create_latex_problem(step):
world.create_course_with_unit() world.create_course_with_unit()
step.given('I have enabled latex compiler')
world.create_component_instance( world.create_component_instance(
step=step, step=step,
category='problem', category='problem',
...@@ -208,11 +224,6 @@ def i_import_the_file(_step, filename): ...@@ -208,11 +224,6 @@ def i_import_the_file(_step, filename):
import_file(filename) import_file(filename)
@step(u'I click on "edit a draft"$')
def i_edit_a_draft(_step):
world.css_click("a.create-draft")
@step(u'I go to the vertical "([^"]*)"$') @step(u'I go to the vertical "([^"]*)"$')
def i_go_to_vertical(_step, vertical): def i_go_to_vertical(_step, vertical):
world.css_click("span:contains('{0}')".format(vertical)) world.css_click("span:contains('{0}')".format(vertical))
......
...@@ -26,4 +26,4 @@ Feature: CMS.Sign in ...@@ -26,4 +26,4 @@ Feature: CMS.Sign in
And I visit the url "/signin?next=http://www.google.com/" And I visit the url "/signin?next=http://www.google.com/"
When I fill in and submit the signin form When I fill in and submit the signin form
And I wait for "2" seconds And I wait for "2" seconds
Then I should see that the path is "/" Then I should see that the path is "/course"
...@@ -9,10 +9,8 @@ Feature: CMS.Static Pages ...@@ -9,10 +9,8 @@ Feature: CMS.Static Pages
Then I should see a static page named "Empty" Then I should see a static page named "Empty"
Scenario: Users can delete static pages Scenario: Users can delete static pages
Given I have opened a new course in Studio Given I have created a static page
And I go to the static pages page When I "delete" the static page
And I add a new page
And I "delete" the static page
Then I am shown a prompt Then I am shown a prompt
When I confirm the prompt When I confirm the prompt
Then I should not see any static pages Then I should not see any static pages
...@@ -20,9 +18,16 @@ Feature: CMS.Static Pages ...@@ -20,9 +18,16 @@ Feature: CMS.Static Pages
# Safari won't update the name properly # Safari won't update the name properly
@skip_safari @skip_safari
Scenario: Users can edit static pages Scenario: Users can edit static pages
Given I have opened a new course in Studio Given I have created a static page
And I go to the static pages page
And I add a new page
When I "edit" the static page When I "edit" the static page
And I change the name to "New" And I change the name to "New"
Then I should see a static page named "New" Then I should see a static page named "New"
# Safari won't update the name properly
@skip_safari
Scenario: Users can reorder static pages
Given I have created two different static pages
When I reorder the tabs
Then the tabs are in the reverse order
And I reload the page
Then the tabs are in the reverse order
...@@ -48,3 +48,47 @@ def change_name(step, new_name): ...@@ -48,3 +48,47 @@ def change_name(step, new_name):
world.trigger_event(input_css) world.trigger_event(input_css)
save_button = 'a.save-button' save_button = 'a.save-button'
world.css_click(save_button) world.css_click(save_button)
@step(u'I reorder the tabs')
def reorder_tabs(_step):
# For some reason, the drag_and_drop method did not work in this case.
draggables = world.css_find('.drag-handle')
source = draggables.first
target = draggables.last
source.action_chains.click_and_hold(source._element).perform()
source.action_chains.move_to_element_with_offset(target._element, 0, 50).perform()
source.action_chains.release().perform()
@step(u'I have created a static page')
def create_static_page(step):
step.given('I have opened a new course in Studio')
step.given('I go to the static pages page')
step.given('I add a new page')
@step(u'I have created two different static pages')
def create_two_pages(step):
step.given('I have created a static page')
step.given('I "edit" the static page')
step.given('I change the name to "First"')
step.given('I add a new page')
# Verify order of tabs
_verify_tab_names('First', 'Empty')
@step(u'the tabs are in the reverse order')
def tabs_in_reverse_order(step):
_verify_tab_names('Empty', 'First')
def _verify_tab_names(first, second):
world.wait_for(
func=lambda _: len(world.css_find('.xmodule_StaticTabModule')) == 2,
timeout=200,
timeout_msg="Timed out waiting for two tabs to be present"
)
tabs = world.css_find('.xmodule_StaticTabModule')
assert tabs[0].text == first
assert tabs[1].text == second
...@@ -11,6 +11,7 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT ...@@ -11,6 +11,7 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
@step(u'I go to the textbooks page') @step(u'I go to the textbooks page')
def go_to_uploads(_step): def go_to_uploads(_step):
world.wait_for_js_to_load()
world.click_course_content() world.click_course_content()
menu_css = 'li.nav-course-courseware-textbooks a' menu_css = 'li.nav-course-courseware-textbooks a'
world.css_click(menu_css) world.css_click(menu_css)
......
...@@ -641,6 +641,7 @@ Feature: Video Component Editor ...@@ -641,6 +641,7 @@ Feature: Video Component Editor
And I save changes And I save changes
Then when I view the video it does show the captions Then when I view the video it does show the captions
And I see "好 各位同学" text in the captions
And I edit the component And I edit the component
And I open tab "Advanced" And I open tab "Advanced"
......
...@@ -116,6 +116,7 @@ def i_see_status_message(_step, status): ...@@ -116,6 +116,7 @@ def i_see_status_message(_step, status):
world.wait(DELAY) world.wait(DELAY)
world.wait_for_ajax_complete() world.wait_for_ajax_complete()
assert not world.css_visible(SELECTORS['error_bar'])
assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()]) assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()])
......
...@@ -46,13 +46,15 @@ Feature: CMS.Video Component ...@@ -46,13 +46,15 @@ Feature: CMS.Video Component
Scenario: Closed captions become visible when the mouse hovers over CC button Scenario: Closed captions become visible when the mouse hovers over CC button
Given I have created a Video component with subtitles Given I have created a Video component with subtitles
And Make sure captions are closed And Make sure captions are closed
Then Captions become "invisible" after 3 seconds Then Captions become "invisible"
And I hover over button "CC" And I hover over button "CC"
Then Captions become "visible" Then Captions become "visible"
And I hover over button "volume" And I hover over button "volume"
Then Captions become "invisible" after 3 seconds Then Captions become "invisible"
# 8 # 8
# Disabled 11/26 due to flakiness in master.
# Enabled back on 11/29.
Scenario: Open captions never become invisible Scenario: Open captions never become invisible
Given I have created a Video component with subtitles Given I have created a Video component with subtitles
And Make sure captions are open And Make sure captions are open
...@@ -63,35 +65,33 @@ Feature: CMS.Video Component ...@@ -63,35 +65,33 @@ Feature: CMS.Video Component
Then Captions are "visible" Then Captions are "visible"
# 9 # 9
# Disabled 11/26 due to flakiness in master.
# Enabled back on 11/29.
Scenario: Closed captions are invisible when mouse doesn't hover on CC button Scenario: Closed captions are invisible when mouse doesn't hover on CC button
Given I have created a Video component with subtitles Given I have created a Video component with subtitles
And Make sure captions are closed And Make sure captions are closed
Then Captions become "invisible" after 3 seconds Then Captions become "invisible"
And I hover over button "volume" And I hover over button "volume"
Then Captions are "invisible" Then Captions are "invisible"
# 10 # 10
# Disabled 11/26 due to flakiness in master.
# Enabled back on 11/29.
Scenario: When enter key is pressed on a caption shows an outline around it Scenario: When enter key is pressed on a caption shows an outline around it
Given I have created a Video component with subtitles Given I have created a Video component with subtitles
And Make sure captions are opened And Make sure captions are opened
Then I focus on caption line with data-index 0 Then I focus on caption line with data-index "0"
Then I press "enter" button on caption line with data-index 0 Then I press "enter" button on caption line with data-index "0"
And I see caption line with data-index 0 has class "focused" And I see caption line with data-index "0" has class "focused"
# 11 # 11
# Disabled until we come up with a more solid test, as this one is brittle. Scenario: When start end end times are specified, a range on slider is shown
# Scenario: When start end end times are specified, a range on slider is shown Given I have created a Video component
# Given I have created a Video component And Make sure captions are closed
# And Make sure captions are closed And I edit the component
# And I edit the component And I open tab "Advanced"
# And I open tab "Advanced" And I set value "00:00:12" to the field "Start Time"
# And I set value "12" to the field "Start Time" And I set value "00:00:24" to the field "End Time"
# And I set value "24" to the field "End Time" And I save changes
# And I save changes And I click video button "play"
# And I click video button "Play" Then I see a range on slider
# The below line is a bit flaky. Numbers 73 and 73 were determined rather
# accidentally. They might change in the future as Video player gets CSS
# updates. If this test starts failing, 99.9% cause of failure is the line
# below.
# Then I see a range on slider with styles "left" set to 73 px and "width" set to 73 px
...@@ -8,7 +8,13 @@ from selenium.webdriver.common.keys import Keys ...@@ -8,7 +8,13 @@ from selenium.webdriver.common.keys import Keys
VIDEO_BUTTONS = { VIDEO_BUTTONS = {
'CC': '.hide-subtitles', 'CC': '.hide-subtitles',
'volume': '.volume', 'volume': '.volume',
'Play': '.video_control.play', 'play': '.video_control.play',
'pause': '.video_control.pause',
}
SELECTORS = {
'spinner': '.video-wrapper .spinner',
'controls': 'section.video-controls',
} }
# We should wait 300 ms for event handler invocation + 200ms for safety. # We should wait 300 ms for event handler invocation + 200ms for safety.
...@@ -23,6 +29,13 @@ def i_created_a_video_component(step): ...@@ -23,6 +29,13 @@ def i_created_a_video_component(step):
category='video', category='video',
) )
world.wait_for_xmodule()
world.disable_jquery_animations()
world.wait_for_present('.is-initialized')
world.wait(DELAY)
assert not world.css_visible(SELECTORS['spinner'])
@step('I have created a Video component with subtitles$') @step('I have created a Video component with subtitles$')
def i_created_a_video_with_subs(_step): def i_created_a_video_with_subs(_step):
...@@ -41,7 +54,13 @@ def i_created_a_video_with_subs_with_name(_step, sub_id): ...@@ -41,7 +54,13 @@ def i_created_a_video_with_subs_with_name(_step, sub_id):
# Return to the video # Return to the video
world.visit(video_url) world.visit(video_url)
world.wait_for_xmodule() world.wait_for_xmodule()
world.disable_jquery_animations()
world.wait_for_present('.is-initialized')
world.wait(DELAY)
assert not world.css_visible(SELECTORS['spinner'])
@step('I have uploaded subtitles "([^"]*)"$') @step('I have uploaded subtitles "([^"]*)"$')
...@@ -52,7 +71,6 @@ def i_have_uploaded_subtitles(_step, sub_id): ...@@ -52,7 +71,6 @@ def i_have_uploaded_subtitles(_step, sub_id):
@step('when I view the (.*) it does not have autoplay enabled$') @step('when I view the (.*) it does not have autoplay enabled$')
def does_not_autoplay(_step, video_type): def does_not_autoplay(_step, video_type):
world.wait_for_xmodule()
assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False' assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False'
assert world.css_has_class('.video_control', 'play') assert world.css_has_class('.video_control', 'play')
...@@ -73,7 +91,6 @@ def i_edit_the_component(_step): ...@@ -73,7 +91,6 @@ def i_edit_the_component(_step):
@step('I have (hidden|toggled) captions$') @step('I have (hidden|toggled) captions$')
def hide_or_show_captions(step, shown): def hide_or_show_captions(step, shown):
world.wait_for_xmodule()
button_css = 'a.hide-subtitles' button_css = 'a.hide-subtitles'
if shown == 'hidden': if shown == 'hidden':
world.css_click(button_css) world.css_click(button_css)
...@@ -118,18 +135,18 @@ def xml_only_video(step): ...@@ -118,18 +135,18 @@ def xml_only_video(step):
@step('The correct Youtube video is shown$') @step('The correct Youtube video is shown$')
def the_youtube_video_is_shown(_step): def the_youtube_video_is_shown(_step):
world.wait_for_xmodule()
ele = world.css_find('.video').first ele = world.css_find('.video').first
assert ele['data-streams'].split(':')[1] == world.scenario_dict['YOUTUBE_ID'] assert ele['data-streams'].split(':')[1] == world.scenario_dict['YOUTUBE_ID']
@step('Make sure captions are (.+)$') @step('Make sure captions are (.+)$')
def set_captions_visibility_state(_step, captions_state): def set_captions_visibility_state(_step, captions_state):
SELECTOR = '.closed .subtitles'
if captions_state == 'closed': if captions_state == 'closed':
if world.css_visible('.subtitles'): if not world.is_css_present(SELECTOR):
world.browser.find_by_css('.hide-subtitles').click() world.browser.find_by_css('.hide-subtitles').click()
else: else:
if not world.css_visible('.subtitles'): if world.is_css_present(SELECTOR):
world.browser.find_by_css('.hide-subtitles').click() world.browser.find_by_css('.hide-subtitles').click()
...@@ -139,18 +156,7 @@ def hover_over_button(_step, button): ...@@ -139,18 +156,7 @@ def hover_over_button(_step, button):
@step('Captions (?:are|become) "([^"]*)"$') @step('Captions (?:are|become) "([^"]*)"$')
def are_captions_visibile(_step, visibility_state): def check_captions_visibility_state(_step, visibility_state):
_step.given('Captions become "{0}" after 0 seconds'.format(visibility_state))
@step('Captions (?:are|become) "([^"]*)" after (.+) seconds$')
def check_captions_visibility_state(_step, visibility_state, timeout):
timeout = int(timeout.strip())
# Captions become invisible by fading out. We must wait by a specified
# time.
world.wait(timeout)
if visibility_state == 'visible': if visibility_state == 'visible':
assert world.css_visible('.subtitles') assert world.css_visible('.subtitles')
else: else:
...@@ -162,33 +168,27 @@ def find_caption_line_by_data_index(index): ...@@ -162,33 +168,27 @@ def find_caption_line_by_data_index(index):
return world.css_find(SELECTOR).first return world.css_find(SELECTOR).first
@step('I focus on caption line with data-index (\d+)$') @step('I focus on caption line with data-index "([^"]*)"$')
def focus_on_caption_line(_step, index): def focus_on_caption_line(_step, index):
find_caption_line_by_data_index(int(index.strip()))._element.send_keys(Keys.TAB) find_caption_line_by_data_index(int(index.strip()))._element.send_keys(Keys.TAB)
@step('I press "enter" button on caption line with data-index (\d+)$') @step('I press "enter" button on caption line with data-index "([^"]*)"$')
def focus_on_caption_line(_step, index): def click_on_the_caption(_step, index):
find_caption_line_by_data_index(int(index.strip()))._element.send_keys(Keys.ENTER) find_caption_line_by_data_index(int(index.strip()))._element.send_keys(Keys.ENTER)
@step('I see caption line with data-index (\d+) has class "([^"]*)"$') @step('I see caption line with data-index "([^"]*)" has class "([^"]*)"$')
def caption_line_has_class(_step, index, className): def caption_line_has_class(_step, index, className):
SELECTOR = ".subtitles > li[data-index='{index}']".format(index=int(index.strip())) SELECTOR = ".subtitles > li[data-index='{index}']".format(index=int(index.strip()))
world.css_has_class(SELECTOR, className.strip()) assert world.css_has_class(SELECTOR, className.strip())
@step('I see a range on slider with styles "left" set to (.+) px and "width" set to (.+) px$')
def see_a_range_slider_with_proper_range(_step, left, width):
left = int(left.strip())
width = int(width.strip())
world.wait_for_visible(".slider-range") @step('I see a range on slider$')
world.wait(4) def see_a_range_slider_with_proper_range(_step):
slider_range = world.browser.driver.find_element_by_css_selector(".slider-range") world.wait_for_visible(VIDEO_BUTTONS['pause'])
assert int(round(float(slider_range.value_of_css_property("left")[:-2]))) == left assert world.css_visible(".slider-range")
assert int(round(float(slider_range.value_of_css_property("width")[:-2]))) == width
@step('I click video button "([^"]*)"$') @step('I click video button "([^"]*)"$')
......
"""
Script for finding all courses whose org/name pairs == other courses when ignoring case
"""
from django.core.management.base import BaseCommand
from xmodule.modulestore.django import modulestore
#
# To run from command line: ./manage.py cms --settings dev course_id_clash
#
class Command(BaseCommand):
"""
Script for finding all courses whose org/name pairs == other courses when ignoring case
"""
help = 'List all courses ids which may collide when ignoring case'
def handle(self, *args, **options):
mstore = modulestore()
if hasattr(mstore, 'collection'):
map_fn = '''
function () {
emit(this._id.org.toLowerCase()+this._id.course.toLowerCase(), {target: this._id});
}
'''
reduce_fn = '''
function (idpair, matches) {
var result = {target: []};
matches.forEach(function (match) {
result.target.push(match.target);
});
return result;
}
'''
finalize = '''
function(key, reduced) {
if (Array.isArray(reduced.target)) {
return reduced;
}
else {return null;}
}
'''
results = mstore.collection.map_reduce(
map_fn, reduce_fn, {'inline': True}, query={'_id.category': 'course'}, finalize=finalize
)
results = results.get('results')
for entry in results:
if entry.get('value') is not None:
print '{:-^40}'.format(entry.get('_id'))
for course_id in entry.get('value').get('target'):
print ' {}/{}/{}'.format(course_id.get('org'), course_id.get('course'), course_id.get('name'))
...@@ -20,6 +20,7 @@ from contentstore.views import tabs ...@@ -20,6 +20,7 @@ from contentstore.views import tabs
def print_course(course): def print_course(course):
"Prints out the course id and a numbered list of tabs." "Prints out the course id and a numbered list of tabs."
print course.id print course.id
print 'num type name'
for index, item in enumerate(course.tabs): for index, item in enumerate(course.tabs):
print index + 1, '"' + item.get('type') + '"', '"' + item.get('name', '') + '"' print index + 1, '"' + item.get('type') + '"', '"' + item.get('name', '') + '"'
...@@ -31,13 +32,16 @@ def print_course(course): ...@@ -31,13 +32,16 @@ def print_course(course):
class Command(BaseCommand): class Command(BaseCommand):
help = """See and edit a course's tabs list. help = """See and edit a course's tabs list. Only supports insertion
Only supports insertion and deletion. Move and and deletion. Move and rename etc. can be done with a delete
rename etc. can be done with a delete followed by an insert. The tabs are numbered starting with 1.
followed by an insert. Tabs 1 and 2 cannot be changed, and tabs of type static_tab cannot
The tabs are numbered starting with 1. be edited (use Studio for those).
Tabs 1 and 2 cannot be changed, and tabs of type
static_tab cannot be edited (use Studio for those). As a first step, run the command with a courseid like this:
--course Stanford/CS99/2013_spring
This will print the existing tabs types and names. Then run the
command again, adding --insert or --delete to edit the list.
""" """
# Making these option objects separately, so can refer to their .help below # Making these option objects separately, so can refer to their .help below
course_option = make_option('--course', course_option = make_option('--course',
......
import sys
from StringIO import StringIO
from django.test import TestCase
from django.core.management import call_command
from xmodule.modulestore.tests.factories import CourseFactory
class ClashIdTestCase(TestCase):
"""
Test for course_id_clash.
"""
def test_course_clash(self):
"""
Test for course_id_clash.
"""
expected = []
# clashing courses
course = CourseFactory.create(org="test", course="courseid", display_name="run1")
expected.append(course.location.course_id)
course = CourseFactory.create(org="TEST", course="courseid", display_name="RUN12")
expected.append(course.location.course_id)
course = CourseFactory.create(org="test", course="CourseId", display_name="aRUN123")
expected.append(course.location.course_id)
# not clashing courses
not_expected = []
course = CourseFactory.create(org="test", course="course2", display_name="run1")
not_expected.append(course.location.course_id)
course = CourseFactory.create(org="test1", course="courseid", display_name="run1")
not_expected.append(course.location.course_id)
course = CourseFactory.create(org="test", course="courseid0", display_name="run1")
not_expected.append(course.location.course_id)
old_stdout = sys.stdout
sys.stdout = mystdout = StringIO()
call_command('course_id_clash', stdout=mystdout)
sys.stdout = old_stdout
result = mystdout.getvalue()
for courseid in expected:
self.assertIn(courseid, result)
for courseid in not_expected:
self.assertNotIn(courseid, result)
from static_replace import replace_static_urls
from xmodule.modulestore.exceptions import ItemNotFoundError
def get_module_info(store, location, rewrite_static_links=False):
try:
module = store.get_item(location)
except ItemNotFoundError:
# create a new one
store.create_and_save_xmodule(location)
module = store.get_item(location)
data = module.data
if rewrite_static_links:
# we pass a partially bogus course_id as we don't have the RUN information passed yet
# through the CMS. Also the contentstore is also not RUN-aware at this point in time.
data = replace_static_urls(
module.data,
None,
course_id=module.location.org + '/' + module.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
)
return {
'id': module.location.url(),
'data': data,
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
# what's the intent here? all metadata incl inherited & namespaced?
'metadata': module.xblock_kvs._metadata
}
def set_module_info(store, location, post_data):
module = None
try:
module = store.get_item(location)
except ItemNotFoundError:
# new module at this location: almost always used for the course about pages; thus, no parent. (there
# are quite a handful of about page types available for a course and only the overview is pre-created)
store.create_and_save_xmodule(location)
module = store.get_item(location)
if post_data.get('data') is not None:
data = post_data['data']
store.update_item(location, data)
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
# deleting the children object from the children collection
if 'children' in post_data and post_data['children'] is not None:
children = post_data['children']
store.update_children(location, children)
# cdodge: also commit any metadata which might have been passed along in the
# POST from the client, if it is there
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
# not presented to the end-user for editing. So let's fetch the original and
# 'apply' the submitted metadata, so we don't end up deleting system metadata
if post_data.get('metadata') is not None:
posted_metadata = post_data['metadata']
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
for metadata_key, value in posted_metadata.items():
if posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if module._field_data.has(module, metadata_key):
module._field_data.delete(module, metadata_key)
del posted_metadata[metadata_key]
else:
module._field_data.set(module, metadata_key, value)
# commit to datastore
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
store.update_metadata(location, module.xblock_kvs._metadata)
...@@ -105,7 +105,7 @@ class ChecklistTestCase(CourseTestCase): ...@@ -105,7 +105,7 @@ class ChecklistTestCase(CourseTestCase):
self.assertEqual('CourseOutline', get_first_item(payload).get('action_url')) self.assertEqual('CourseOutline', get_first_item(payload).get('action_url'))
get_first_item(payload)['is_checked'] = True get_first_item(payload)['is_checked'] = True
returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content) returned_checklist = json.loads(self.client.ajax_post(update_url, payload).content)
self.assertTrue(get_first_item(returned_checklist).get('is_checked')) self.assertTrue(get_first_item(returned_checklist).get('is_checked'))
persisted_checklist = self.get_persisted_checklists()[1] persisted_checklist = self.get_persisted_checklists()[1]
# Verify that persisted checklist does not have expanded action URLs. # Verify that persisted checklist does not have expanded action URLs.
......
...@@ -3,7 +3,6 @@ Unit tests for getting the list of courses and the course outline. ...@@ -3,7 +3,6 @@ Unit tests for getting the list of courses and the course outline.
""" """
import json import json
import lxml import lxml
from django.core.urlresolvers import reverse
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
...@@ -31,7 +30,7 @@ class TestCourseIndex(CourseTestCase): ...@@ -31,7 +30,7 @@ class TestCourseIndex(CourseTestCase):
""" """
Test getting the list of courses and then pulling up their outlines Test getting the list of courses and then pulling up their outlines
""" """
index_url = reverse('contentstore.views.index') index_url = '/course'
index_response = authed_client.get(index_url, {}, HTTP_ACCEPT='text/html') index_response = authed_client.get(index_url, {}, HTTP_ACCEPT='text/html')
parsed_html = lxml.html.fromstring(index_response.content) parsed_html = lxml.html.fromstring(index_response.content)
course_link_eles = parsed_html.find_class('course-link') course_link_eles = parsed_html.find_class('course-link')
...@@ -60,8 +59,7 @@ class TestCourseIndex(CourseTestCase): ...@@ -60,8 +59,7 @@ class TestCourseIndex(CourseTestCase):
""" """
Test the error conditions for the access Test the error conditions for the access
""" """
locator = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True) outline_url = self.course_locator.url_reverse('course/', '')
outline_url = locator.url_reverse('course/', '')
# register a non-staff member and try to delete the course branch # register a non-staff member and try to delete the course branch
non_staff_client, _ = self.createNonStaffAuthedUserClient() non_staff_client, _ = self.createNonStaffAuthedUserClient()
response = non_staff_client.delete(outline_url, {}, HTTP_ACCEPT='application/json') response = non_staff_client.delete(outline_url, {}, HTTP_ACCEPT='application/json')
......
'''unit tests for course_info views and models.''' '''unit tests for course_info views and models.'''
from contentstore.tests.test_course_settings import CourseTestCase from contentstore.tests.test_course_settings import CourseTestCase
from django.core.urlresolvers import reverse
import json import json
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore, loc_mapper
class CourseUpdateTest(CourseTestCase): class CourseUpdateTest(CourseTestCase):
...@@ -15,62 +14,61 @@ class CourseUpdateTest(CourseTestCase): ...@@ -15,62 +14,61 @@ class CourseUpdateTest(CourseTestCase):
Does not supply a provided_id. Does not supply a provided_id.
""" """
payload = {'content': content, payload = {'content': content, 'date': date}
'date': date} url = update_locator.url_reverse('course_info_update/')
url = reverse('course_info_json',
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json") resp = self.client.ajax_post(url, payload)
self.assertContains(resp, '', status_code=200)
return json.loads(resp.content) return json.loads(resp.content)
# first get the update to force the creation course_locator = loc_mapper().translate_location(
url = reverse('course_info', self.course.location.course_id, self.course.location, False, True
kwargs={'org': self.course.location.org, )
'course': self.course.location.course, resp = self.client.get_html(course_locator.url_reverse('course_info/'))
'name': self.course.location.name}) self.assertContains(resp, 'Course Updates', status_code=200)
self.client.get(url) update_locator = loc_mapper().translate_location(
self.course.location.course_id, self.course.location.replace(category='course_info', name='updates'),
False, True
)
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">' init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
content = init_content + '</iframe>' content = init_content + '</iframe>'
payload = get_response(content, 'January 8, 2013') payload = get_response(content, 'January 8, 2013')
self.assertHTMLEqual(payload['content'], content) self.assertHTMLEqual(payload['content'], content)
first_update_url = reverse('course_info_json', first_update_url = update_locator.url_reverse('course_info_update', str(payload['id']))
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': payload['id']})
content += '<div>div <p>p<br/></p></div>' content += '<div>div <p>p<br/></p></div>'
payload['content'] = content payload['content'] = content
# POST requests were coming in w/ these header values causing an error; so, repro error here # POST requests were coming in w/ these header values causing an error; so, repro error here
resp = self.client.post(first_update_url, json.dumps(payload), resp = self.client.ajax_post(
"application/json", first_update_url, payload, HTTP_X_HTTP_METHOD_OVERRIDE="PUT", REQUEST_METHOD="POST"
HTTP_X_HTTP_METHOD_OVERRIDE="PUT", )
REQUEST_METHOD="POST")
self.assertHTMLEqual(content, json.loads(resp.content)['content'], self.assertHTMLEqual(content, json.loads(resp.content)['content'],
"iframe w/ div") "iframe w/ div")
# refetch using provided id
refetched = self.client.get_json(first_update_url)
self.assertHTMLEqual(
content, json.loads(refetched.content)['content'], "get w/ provided id"
)
# now put in an evil update # now put in an evil update
content = '<ol/>' content = '<ol/>'
payload = get_response(content, 'January 11, 2013') payload = get_response(content, 'January 11, 2013')
self.assertHTMLEqual(content, payload['content'], "self closing ol") self.assertHTMLEqual(content, payload['content'], "self closing ol")
url = reverse('course_info_json', course_update_url = update_locator.url_reverse('course_info_update/')
kwargs={'org': self.course.location.org, resp = self.client.get_json(course_update_url)
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.get(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertTrue(len(payload) == 2) self.assertTrue(len(payload) == 2)
# can't test non-json paylod b/c expect_json throws error
# try json w/o required fields # try json w/o required fields
self.assertContains(self.client.post(url, json.dumps({'garbage': 1}), self.assertContains(
"application/json"), self.client.ajax_post(course_update_url, {'garbage': 1}),
'Failed to save', status_code=400) 'Failed to save', status_code=400
)
# test an update with text in the tail of the header # test an update with text in the tail of the header
content = 'outside <strong>inside</strong> after' content = 'outside <strong>inside</strong> after'
...@@ -78,28 +76,22 @@ class CourseUpdateTest(CourseTestCase): ...@@ -78,28 +76,22 @@ class CourseUpdateTest(CourseTestCase):
self.assertHTMLEqual(content, payload['content'], "text outside tag") self.assertHTMLEqual(content, payload['content'], "text outside tag")
# now try to update a non-existent update # now try to update a non-existent update
url = reverse('course_info_json',
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': '9'})
content = 'blah blah' content = 'blah blah'
payload = {'content': content, payload = {'content': content, 'date': 'January 21, 2013'}
'date': 'January 21, 2013'}
self.assertContains( self.assertContains(
self.client.post(url, json.dumps(payload), "application/json"), self.client.ajax_post(course_update_url + '/9', payload),
'Failed to save', status_code=400) 'Failed to save', status_code=400
)
# update w/ malformed html # update w/ malformed html
content = '<garbage tag No closing brace to force <span>error</span>' content = '<garbage tag No closing brace to force <span>error</span>'
payload = {'content': content, payload = {'content': content,
'date': 'January 11, 2013'} 'date': 'January 11, 2013'}
url = reverse('course_info_json', kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
self.assertContains( self.assertContains(
self.client.post(url, json.dumps(payload), "application/json"), self.client.ajax_post(course_update_url, payload),
'<garbage') '<garbage'
)
# set to valid html which would break an xml parser # set to valid html which would break an xml parser
content = "<p><br><br></p>" content = "<p><br><br></p>"
...@@ -107,10 +99,7 @@ class CourseUpdateTest(CourseTestCase): ...@@ -107,10 +99,7 @@ class CourseUpdateTest(CourseTestCase):
self.assertHTMLEqual(content, payload['content']) self.assertHTMLEqual(content, payload['content'])
# now try to delete a non-existent update # now try to delete a non-existent update
url = reverse('course_info_json', kwargs={'org': self.course.location.org, self.assertContains(self.client.delete(course_update_url + '/19'), "delete", status_code=400)
'course': self.course.location.course,
'provided_id': '19'})
self.assertContains(self.client.delete(url), "delete", status_code=400)
# now delete a real update # now delete a real update
content = 'blah blah' content = 'blah blah'
...@@ -118,18 +107,11 @@ class CourseUpdateTest(CourseTestCase): ...@@ -118,18 +107,11 @@ class CourseUpdateTest(CourseTestCase):
this_id = payload['id'] this_id = payload['id']
self.assertHTMLEqual(content, payload['content'], "single iframe") self.assertHTMLEqual(content, payload['content'], "single iframe")
# first count the entries # first count the entries
url = reverse('course_info_json', resp = self.client.get_json(course_update_url)
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.get(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
before_delete = len(payload) before_delete = len(payload)
url = reverse('course_info_json', url = update_locator.url_reverse('course_info_update/', str(this_id))
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': this_id})
resp = self.client.delete(url) resp = self.client.delete(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertTrue(len(payload) == before_delete - 1) self.assertTrue(len(payload) == before_delete - 1)
...@@ -145,24 +127,19 @@ class CourseUpdateTest(CourseTestCase): ...@@ -145,24 +127,19 @@ class CourseUpdateTest(CourseTestCase):
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">' init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
content = init_content + '</iframe>' content = init_content + '</iframe>'
payload = {'content': content, payload = {'content': content, 'date': 'January 8, 2013'}
'date': 'January 8, 2013'}
url = reverse('course_info_json',
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.post(url, json.dumps(payload), "application/json") update_locator = loc_mapper().translate_location(
self.course.location.course_id, location, False, True
)
course_update_url = update_locator.url_reverse('course_info_update/')
resp = self.client.ajax_post(course_update_url, payload)
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertHTMLEqual(payload['content'], content) self.assertHTMLEqual(payload['content'], content)
# now confirm that the bad news and the iframe make up 2 updates # now confirm that the bad news and the iframe make up 2 updates
url = reverse('course_info_json', resp = self.client.get_json(course_update_url)
kwargs={'org': self.course.location.org,
'course': self.course.location.course,
'provided_id': ''})
resp = self.client.get(url)
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertTrue(len(payload) == 2) self.assertTrue(len(payload) == 2)
from unittest import skip from unittest import skip
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test.client import Client
from django.test.utils import override_settings from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.modulestore_config import TEST_MODULESTORE from contentstore.tests.modulestore_config import TEST_MODULESTORE
from contentstore.tests.utils import AjaxEnabledTestClient
@override_settings(MODULESTORE=TEST_MODULESTORE) @override_settings(MODULESTORE=TEST_MODULESTORE)
...@@ -45,10 +44,10 @@ class InternationalizationTest(ModuleStoreTestCase): ...@@ -45,10 +44,10 @@ class InternationalizationTest(ModuleStoreTestCase):
def test_course_plain_english(self): def test_course_plain_english(self):
"""Test viewing the index page with no courses""" """Test viewing the index page with no courses"""
self.client = Client() self.client = AjaxEnabledTestClient()
self.client.login(username=self.uname, password=self.password) self.client.login(username=self.uname, password=self.password)
resp = self.client.get(reverse('index')) resp = self.client.get_html('/course')
self.assertContains(resp, self.assertContains(resp,
'<h1 class="page-header">My Courses</h1>', '<h1 class="page-header">My Courses</h1>',
status_code=200, status_code=200,
...@@ -56,10 +55,10 @@ class InternationalizationTest(ModuleStoreTestCase): ...@@ -56,10 +55,10 @@ class InternationalizationTest(ModuleStoreTestCase):
def test_course_explicit_english(self): def test_course_explicit_english(self):
"""Test viewing the index page with no courses""" """Test viewing the index page with no courses"""
self.client = Client() self.client = AjaxEnabledTestClient()
self.client.login(username=self.uname, password=self.password) self.client.login(username=self.uname, password=self.password)
resp = self.client.get(reverse('index'), resp = self.client.get_html('/course',
{}, {},
HTTP_ACCEPT_LANGUAGE='en' HTTP_ACCEPT_LANGUAGE='en'
) )
...@@ -74,19 +73,20 @@ class InternationalizationTest(ModuleStoreTestCase): ...@@ -74,19 +73,20 @@ class InternationalizationTest(ModuleStoreTestCase):
# **** # ****
# #
# This test will break when we replace this fake 'test' language # This test will break when we replace this fake 'test' language
# with actual French. This test will need to be updated with # with actual Esperanto. This test will need to be updated with
# actual French at that time. # actual Esperanto at that time.
# Test temporarily disable since it depends on creation of dummy strings # Test temporarily disable since it depends on creation of dummy strings
@skip @skip
def test_course_with_accents(self): def test_course_with_accents(self):
"""Test viewing the index page with no courses""" """Test viewing the index page with no courses"""
self.client = Client() self.client = AjaxEnabledTestClient()
self.client.login(username=self.uname, password=self.password) self.client.login(username=self.uname, password=self.password)
resp = self.client.get(reverse('index'), resp = self.client.get_html(
{}, '/course',
HTTP_ACCEPT_LANGUAGE='fr' {},
) HTTP_ACCEPT_LANGUAGE='eo'
)
TEST_STRING = ( TEST_STRING = (
u'<h1 class="title-1">' u'<h1 class="title-1">'
......
...@@ -13,11 +13,12 @@ from uuid import uuid4 ...@@ -13,11 +13,12 @@ from uuid import uuid4
from pymongo import MongoClient from pymongo import MongoClient
from .utils import CourseTestCase from .utils import CourseTestCase
from django.core.urlresolvers import reverse
from django.test.utils import override_settings from django.test.utils import override_settings
from django.conf import settings from django.conf import settings
from xmodule.modulestore.django import loc_mapper
from xmodule.contentstore.django import _CONTENTSTORE from xmodule.contentstore.django import _CONTENTSTORE
from xmodule.modulestore.tests.factories import ItemFactory
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
...@@ -29,14 +30,12 @@ class ImportTestCase(CourseTestCase): ...@@ -29,14 +30,12 @@ class ImportTestCase(CourseTestCase):
""" """
Unit tests for importing a course Unit tests for importing a course
""" """
def setUp(self): def setUp(self):
super(ImportTestCase, self).setUp() super(ImportTestCase, self).setUp()
self.url = reverse("import_course", kwargs={ self.new_location = loc_mapper().translate_location(
'org': self.course.location.org, self.course.location.course_id, self.course.location, False, True
'course': self.course.location.course, )
'name': self.course.location.name, self.url = self.new_location.url_reverse('import/', '')
})
self.content_dir = path(tempfile.mkdtemp()) self.content_dir = path(tempfile.mkdtemp())
def touch(name): def touch(name):
...@@ -67,13 +66,11 @@ class ImportTestCase(CourseTestCase): ...@@ -67,13 +66,11 @@ class ImportTestCase(CourseTestCase):
self.unsafe_common_dir = path(tempfile.mkdtemp(dir=self.content_dir)) self.unsafe_common_dir = path(tempfile.mkdtemp(dir=self.content_dir))
def tearDown(self): def tearDown(self):
shutil.rmtree(self.content_dir) shutil.rmtree(self.content_dir)
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db']) MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'])
_CONTENTSTORE.clear() _CONTENTSTORE.clear()
def test_no_coursexml(self): def test_no_coursexml(self):
""" """
Check that the response for a tar.gz import without a course.xml is Check that the response for a tar.gz import without a course.xml is
...@@ -89,15 +86,14 @@ class ImportTestCase(CourseTestCase): ...@@ -89,15 +86,14 @@ class ImportTestCase(CourseTestCase):
self.assertEquals(resp.status_code, 415) self.assertEquals(resp.status_code, 415)
# Check that `import_status` returns the appropriate stage (i.e., the # Check that `import_status` returns the appropriate stage (i.e., the
# stage at which import failed). # stage at which import failed).
status_url = reverse("import_status", kwargs={ resp_status = self.client.get(
'org': self.course.location.org, self.new_location.url_reverse(
'course': self.course.location.course, 'import_status',
'name': os.path.split(self.bad_tar)[1], os.path.split(self.bad_tar)[1]
}) )
resp_status = self.client.get(status_url) )
log.debug(str(self.client.session["import_status"]))
self.assertEquals(json.loads(resp_status.content)["ImportStatus"], 2)
self.assertEquals(json.loads(resp_status.content)["ImportStatus"], 2)
def test_with_coursexml(self): def test_with_coursexml(self):
""" """
...@@ -105,23 +101,19 @@ class ImportTestCase(CourseTestCase): ...@@ -105,23 +101,19 @@ class ImportTestCase(CourseTestCase):
correct. correct.
""" """
with open(self.good_tar) as gtar: with open(self.good_tar) as gtar:
resp = self.client.post( args = {"name": self.good_tar, "course-data": [gtar]}
self.url, resp = self.client.post(self.url, args)
{
"name": self.good_tar,
"course-data": [gtar]
})
self.assertEquals(resp.status_code, 200) self.assertEquals(resp.status_code, 200)
## Unsafe tar methods ##################################################### ## Unsafe tar methods #####################################################
# Each of these methods creates a tarfile with a single type of unsafe # Each of these methods creates a tarfile with a single type of unsafe
# content. # content.
def _fifo_tar(self): def _fifo_tar(self):
""" """
Tar file with FIFO Tar file with FIFO
""" """
fifop = self.unsafe_common_dir / "fifo.file" fifop = self.unsafe_common_dir / "fifo.file"
fifo_tar = self.unsafe_common_dir / "fifo.tar.gz" fifo_tar = self.unsafe_common_dir / "fifo.tar.gz"
os.mkfifo(fifop) os.mkfifo(fifop)
with tarfile.open(fifo_tar, "w:gz") as tar: with tarfile.open(fifo_tar, "w:gz") as tar:
...@@ -137,7 +129,7 @@ class ImportTestCase(CourseTestCase): ...@@ -137,7 +129,7 @@ class ImportTestCase(CourseTestCase):
symlinkp = self.unsafe_common_dir / "symlink.txt" symlinkp = self.unsafe_common_dir / "symlink.txt"
symlink_tar = self.unsafe_common_dir / "symlink.tar.gz" symlink_tar = self.unsafe_common_dir / "symlink.tar.gz"
outsidep.symlink(symlinkp) outsidep.symlink(symlinkp)
with tarfile.open(symlink_tar, "w:gz" ) as tar: with tarfile.open(symlink_tar, "w:gz") as tar:
tar.add(symlinkp) tar.add(symlinkp)
return symlink_tar return symlink_tar
...@@ -186,10 +178,8 @@ class ImportTestCase(CourseTestCase): ...@@ -186,10 +178,8 @@ class ImportTestCase(CourseTestCase):
def try_tar(tarpath): def try_tar(tarpath):
with open(tarpath) as tar: with open(tarpath) as tar:
resp = self.client.post( args = { "name": tarpath, "course-data": [tar] }
self.url, resp = self.client.post(self.url, args)
{ "name": tarpath, "course-data": [tar] }
)
self.assertEquals(resp.status_code, 400) self.assertEquals(resp.status_code, 400)
self.assertTrue("SuspiciousFileOperation" in resp.content) self.assertTrue("SuspiciousFileOperation" in resp.content)
...@@ -200,11 +190,85 @@ class ImportTestCase(CourseTestCase): ...@@ -200,11 +190,85 @@ class ImportTestCase(CourseTestCase):
# Check that `import_status` returns the appropriate stage (i.e., # Check that `import_status` returns the appropriate stage (i.e.,
# either 3, indicating all previous steps are completed, or 0, # either 3, indicating all previous steps are completed, or 0,
# indicating no upload in progress) # indicating no upload in progress)
status_url = reverse("import_status", kwargs={ resp_status = self.client.get(
'org': self.course.location.org, self.new_location.url_reverse(
'course': self.course.location.course, 'import_status',
'name': os.path.split(self.good_tar)[1], os.path.split(self.good_tar)[1]
}) )
resp_status = self.client.get(status_url) )
import_status = json.loads(resp_status.content)["ImportStatus"] import_status = json.loads(resp_status.content)["ImportStatus"]
self.assertIn(import_status, (0, 3)) self.assertIn(import_status, (0, 3))
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
class ExportTestCase(CourseTestCase):
"""
Tests for export_handler.
"""
def setUp(self):
"""
Sets up the test course.
"""
super(ExportTestCase, self).setUp()
location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
self.url = location.url_reverse('export/', '')
def test_export_html(self):
"""
Get the HTML for the page.
"""
resp = self.client.get_html(self.url)
self.assertEquals(resp.status_code, 200)
self.assertContains(resp, "Export My Course Content")
def test_export_json_unsupported(self):
"""
JSON is unsupported.
"""
resp = self.client.get(self.url, HTTP_ACCEPT='application/json')
self.assertEquals(resp.status_code, 406)
def test_export_targz(self):
"""
Get tar.gz file, using HTTP_ACCEPT.
"""
resp = self.client.get(self.url, HTTP_ACCEPT='application/x-tgz')
self._verify_export_succeeded(resp)
def test_export_targz_urlparam(self):
"""
Get tar.gz file, using URL parameter.
"""
resp = self.client.get(self.url + '?_accept=application/x-tgz')
self._verify_export_succeeded(resp)
def _verify_export_succeeded(self, resp):
""" Export success helper method. """
self.assertEquals(resp.status_code, 200)
self.assertTrue(resp.get('Content-Disposition').startswith('attachment'))
def test_export_failure_top_level(self):
"""
Export failure.
"""
ItemFactory.create(parent_location=self.course.location, category='aawefawef')
self._verify_export_failure('/course/MITx.999.Robot_Super_Course/branch/draft/block/Robot_Super_Course')
def test_export_failure_subsection_level(self):
"""
Slightly different export failure.
"""
vertical = ItemFactory.create(parent_location=self.course.location, category='vertical', display_name='foo')
ItemFactory.create(
parent_location=vertical.location,
category='aawefawef'
)
self._verify_export_failure(u'/unit/MITx.999.Robot_Super_Course/branch/draft/block/foo')
def _verify_export_failure(self, expectedText):
""" Export failure helper method. """
resp = self.client.get(self.url, HTTP_ACCEPT='application/x-tgz')
self.assertEquals(resp.status_code, 200)
self.assertIsNone(resp.get('Content-Disposition'))
self.assertContains(resp, 'Unable to create xml for module')
self.assertContains(resp, expectedText)
"""
Test CRUD for authorization.
"""
from django.test.utils import override_settings
from django.contrib.auth.models import User, Group
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.modulestore_config import TEST_MODULESTORE
from contentstore.tests.utils import AjaxEnabledTestClient
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore import Location
from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME
from auth import authz
import copy
from contentstore.views.access import has_access
@override_settings(MODULESTORE=TEST_MODULESTORE)
class TestCourseAccess(ModuleStoreTestCase):
"""
Course-based access (as opposed to access of a non-course xblock)
"""
def setUp(self):
"""
Create a staff user and log them in (creating the client).
Create a pool of users w/o granting them any permissions
"""
super(TestCourseAccess, self).setUp()
uname = 'testuser'
email = 'test+courses@edx.org'
password = 'foo'
# Create the use so we can log them in.
self.user = User.objects.create_user(uname, email, password)
# Note that we do not actually need to do anything
# for registration if we directly mark them active.
self.user.is_active = True
# Staff has access to view all courses
self.user.is_staff = True
self.user.save()
self.client = AjaxEnabledTestClient()
self.client.login(username=uname, password=password)
# create a course via the view handler which has a different strategy for permissions than the factory
self.course_location = Location(['i4x', 'myu', 'mydept.mycourse', 'course', 'myrun'])
self.course_locator = loc_mapper().translate_location(
self.course_location.course_id, self.course_location, False, True
)
self.client.ajax_post(
self.course_locator.url_reverse('course'),
{
'org': self.course_location.org,
'number': self.course_location.course,
'display_name': 'My favorite course',
'run': self.course_location.name,
}
)
self.users = self._create_users()
def _create_users(self):
"""
Create 8 users and return them
"""
users = []
for i in range(8):
username = "user{}".format(i)
email = "test+user{}@edx.org".format(i)
user = User.objects.create_user(username, email, 'foo')
user.is_active = True
user.save()
users.append(user)
return users
def tearDown(self):
"""
Reverse the setup
"""
self.client.logout()
ModuleStoreTestCase.tearDown(self)
def test_get_all_users(self):
"""
Test getting all authors for a course where their permissions run the gamut of allowed group
types.
"""
# first check the groupname for the course creator.
self.assertTrue(
self.user.groups.filter(
name="{}_{}".format(INSTRUCTOR_ROLE_NAME, self.course_locator.course_id)
).exists(),
"Didn't add creator as instructor."
)
users = copy.copy(self.users)
user_by_role = {}
# add the misc users to the course in different groups
for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
user_by_role[role] = []
groupnames, _ = authz.get_all_course_role_groupnames(self.course_locator, role)
for groupname in groupnames:
group, _ = Group.objects.get_or_create(name=groupname)
user = users.pop()
user_by_role[role].append(user)
user.groups.add(group)
user.save()
self.assertTrue(has_access(user, self.course_locator), "{} does not have access".format(user))
self.assertTrue(has_access(user, self.course_location), "{} does not have access".format(user))
response = self.client.get_html(self.course_locator.url_reverse('course_team'))
for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
for user in user_by_role[role]:
self.assertContains(response, user.email)
# test copying course permissions
copy_course_location = Location(['i4x', 'copyu', 'copydept.mycourse', 'course', 'myrun'])
copy_course_locator = loc_mapper().translate_location(
copy_course_location.course_id, copy_course_location, False, True
)
# pylint: disable=protected-access
authz._copy_course_group(self.course_locator, copy_course_locator)
for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
for user in user_by_role[role]:
self.assertTrue(has_access(user, copy_course_locator), "{} no copy access".format(user))
self.assertTrue(has_access(user, copy_course_location), "{} no copy access".format(user))
\ No newline at end of file
...@@ -9,6 +9,8 @@ from pymongo import MongoClient ...@@ -9,6 +9,8 @@ from pymongo import MongoClient
from django.test.utils import override_settings from django.test.utils import override_settings
from django.conf import settings from django.conf import settings
from nose.plugins.skip import SkipTest
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
...@@ -185,6 +187,12 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): ...@@ -185,6 +187,12 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase):
_CONTENTSTORE.clear() _CONTENTSTORE.clear()
def test_success_downloading_subs(self): def test_success_downloading_subs(self):
# Disabled 11/14/13
# This test is flakey because it performs an HTTP request on an external service
# Re-enable when `requests.get` is patched using `mock.patch`
raise SkipTest
good_youtube_subs = { good_youtube_subs = {
0.5: 'JMD_ifUUfsU', 0.5: 'JMD_ifUUfsU',
1.0: 'hI10vDNYz4M', 1.0: 'hI10vDNYz4M',
...@@ -206,6 +214,12 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): ...@@ -206,6 +214,12 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase):
self.clear_subs_content(good_youtube_subs) self.clear_subs_content(good_youtube_subs)
def test_fail_downloading_subs(self): def test_fail_downloading_subs(self):
# Disabled 11/14/13
# This test is flakey because it performs an HTTP request on an external service
# Re-enable when `requests.get` is patched using `mock.patch`
raise SkipTest
bad_youtube_subs = { bad_youtube_subs = {
0.5: 'BAD_YOUTUBE_ID1', 0.5: 'BAD_YOUTUBE_ID1',
1.0: 'BAD_YOUTUBE_ID2', 1.0: 'BAD_YOUTUBE_ID2',
...@@ -227,7 +241,13 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): ...@@ -227,7 +241,13 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase):
self.clear_subs_content(bad_youtube_subs) self.clear_subs_content(bad_youtube_subs)
def test_success_downloading_chinise_transcripts(self): def test_success_downloading_chinese_transcripts(self):
# Disabled 11/14/13
# This test is flakey because it performs an HTTP request on an external service
# Re-enable when `requests.get` is patched using `mock.patch`
raise SkipTest
good_youtube_subs = { good_youtube_subs = {
1.0: 'j_jEn79vS3g', # Chinese, utf-8 1.0: 'j_jEn79vS3g', # Chinese, utf-8
} }
......
...@@ -29,8 +29,8 @@ class UsersTestCase(CourseTestCase): ...@@ -29,8 +29,8 @@ class UsersTestCase(CourseTestCase):
self.detail_url = self.location.url_reverse('course_team', self.ext_user.email) self.detail_url = self.location.url_reverse('course_team', self.ext_user.email)
self.inactive_detail_url = self.location.url_reverse('course_team', self.inactive_user.email) self.inactive_detail_url = self.location.url_reverse('course_team', self.inactive_user.email)
self.invalid_detail_url = self.location.url_reverse('course_team', "nonexistent@user.com") self.invalid_detail_url = self.location.url_reverse('course_team', "nonexistent@user.com")
self.staff_groupname = get_course_groupname_for_role(self.course.location, "staff") self.staff_groupname = get_course_groupname_for_role(self.course_locator, "staff")
self.inst_groupname = get_course_groupname_for_role(self.course.location, "instructor") self.inst_groupname = get_course_groupname_for_role(self.course_locator, "instructor")
def test_index(self): def test_index(self):
resp = self.client.get(self.index_url, HTTP_ACCEPT='text/html') resp = self.client.get(self.index_url, HTTP_ACCEPT='text/html')
...@@ -145,18 +145,6 @@ class UsersTestCase(CourseTestCase): ...@@ -145,18 +145,6 @@ class UsersTestCase(CourseTestCase):
self.assertIn("error", result) self.assertIn("error", result)
self.assert_not_enrolled() self.assert_not_enrolled()
def test_detail_post_bad_json(self):
resp = self.client.post(
self.detail_url,
data="{foo}",
content_type="application/json",
HTTP_ACCEPT="application/json",
)
self.assertEqual(resp.status_code, 400)
result = json.loads(resp.content)
self.assertIn("error", result)
self.assert_not_enrolled()
def test_detail_post_no_json(self): def test_detail_post_no_json(self):
resp = self.client.post( resp = self.client.post(
self.detail_url, self.detail_url,
......
from django.test.client import Client
from django.test.utils import override_settings from django.test.utils import override_settings
from django.core.cache import cache from django.core.cache import cache
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from contentstore.tests.utils import parse_json, user, registration from contentstore.tests.utils import parse_json, user, registration, AjaxEnabledTestClient
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
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
...@@ -82,12 +81,12 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -82,12 +81,12 @@ class AuthTestCase(ContentStoreTestCase):
self.email = 'a@b.com' self.email = 'a@b.com'
self.pw = 'xyz' self.pw = 'xyz'
self.username = 'testuser' self.username = 'testuser'
self.client = Client() self.client = AjaxEnabledTestClient()
# clear the cache so ratelimiting won't affect these tests # clear the cache so ratelimiting won't affect these tests
cache.clear() cache.clear()
def check_page_get(self, url, expected): def check_page_get(self, url, expected):
resp = self.client.get(url) resp = self.client.get_html(url)
self.assertEqual(resp.status_code, expected) self.assertEqual(resp.status_code, expected)
return resp return resp
...@@ -152,20 +151,20 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -152,20 +151,20 @@ class AuthTestCase(ContentStoreTestCase):
def test_private_pages_auth(self): def test_private_pages_auth(self):
"""Make sure pages that do require login work.""" """Make sure pages that do require login work."""
auth_pages = ( auth_pages = (
reverse('index'), '/course',
) )
# These are pages that should just load when the user is logged in # These are pages that should just load when the user is logged in
# (no data needed) # (no data needed)
simple_auth_pages = ( simple_auth_pages = (
reverse('index'), '/course',
) )
# need an activated user # need an activated user
self.test_create_account() self.test_create_account()
# Create a new session # Create a new session
self.client = Client() self.client = AjaxEnabledTestClient()
# Not logged in. Should redirect to login. # Not logged in. Should redirect to login.
print('Not logged in') print('Not logged in')
...@@ -184,7 +183,7 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -184,7 +183,7 @@ class AuthTestCase(ContentStoreTestCase):
def test_index_auth(self): def test_index_auth(self):
# not logged in. Should return a redirect. # not logged in. Should return a redirect.
resp = self.client.get(reverse('index')) resp = self.client.get_html('/course')
self.assertEqual(resp.status_code, 302) self.assertEqual(resp.status_code, 302)
# Logged in should work. # Logged in should work.
......
...@@ -10,8 +10,9 @@ from django.test.client import Client ...@@ -10,8 +10,9 @@ from django.test.client import Client
from django.test.utils import override_settings from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from contentstore.tests.modulestore_config import TEST_MODULESTORE from contentstore.tests.modulestore_config import TEST_MODULESTORE
from xmodule.modulestore.django import loc_mapper
def parse_json(response): def parse_json(response):
...@@ -29,6 +30,33 @@ def registration(email): ...@@ -29,6 +30,33 @@ def registration(email):
return Registration.objects.get(user__email=email) return Registration.objects.get(user__email=email)
class AjaxEnabledTestClient(Client):
"""
Convenience class to make testing easier.
"""
def ajax_post(self, path, data=None, content_type="application/json", **kwargs):
"""
Convenience method for client post which serializes the data into json and sets the accept type
to json
"""
if not isinstance(data, basestring):
data = json.dumps(data or {})
kwargs.setdefault("HTTP_X_REQUESTED_WITH", "XMLHttpRequest")
kwargs.setdefault("HTTP_ACCEPT", "application/json")
return self.post(path=path, data=data, content_type=content_type, **kwargs)
def get_html(self, path, data=None, follow=False, **extra):
"""
Convenience method for client.get which sets the accept type to html
"""
return self.get(path, data or {}, follow, HTTP_ACCEPT="text/html", **extra)
def get_json(self, path, data=None, follow=False, **extra):
"""
Convenience method for client.get which sets the accept type to json
"""
return self.get(path, data or {}, follow, HTTP_ACCEPT="application/json", **extra)
@override_settings(MODULESTORE=TEST_MODULESTORE) @override_settings(MODULESTORE=TEST_MODULESTORE)
class CourseTestCase(ModuleStoreTestCase): class CourseTestCase(ModuleStoreTestCase):
def setUp(self): def setUp(self):
...@@ -53,7 +81,7 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -53,7 +81,7 @@ class CourseTestCase(ModuleStoreTestCase):
self.user.is_staff = True self.user.is_staff = True
self.user.save() self.user.save()
self.client = Client() self.client = AjaxEnabledTestClient()
self.client.login(username=uname, password=password) self.client.login(username=uname, password=password)
self.course = CourseFactory.create( self.course = CourseFactory.create(
...@@ -62,6 +90,9 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -62,6 +90,9 @@ class CourseTestCase(ModuleStoreTestCase):
display_name='Robot Super Course', display_name='Robot Super Course',
) )
self.course_location = self.course.location self.course_location = self.course.location
self.course_locator = loc_mapper().translate_location(
self.course.location.course_id, self.course.location, False, True
)
def createNonStaffAuthedUserClient(self): def createNonStaffAuthedUserClient(self):
""" """
...@@ -80,3 +111,16 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -80,3 +111,16 @@ class CourseTestCase(ModuleStoreTestCase):
client = Client() client = Client()
client.login(username=uname, password=password) client.login(username=uname, password=password)
return client, nonstaff return client, nonstaff
def populateCourse(self):
"""
Add 2 chapters, 4 sections, 8 verticals, 16 problems to self.course (branching 2)
"""
def descend(parent, stack):
xblock_type = stack.pop(0)
for _ in range(2):
child = ItemFactory.create(category=xblock_type, parent_location=parent.location)
if stack:
descend(child, stack)
descend(self.course, ['chapter', 'sequential', 'vertical', 'problem'])
...@@ -5,7 +5,6 @@ from util.json_request import JsonResponse ...@@ -5,7 +5,6 @@ from util.json_request import JsonResponse
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.core.urlresolvers import reverse
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
...@@ -22,6 +21,8 @@ from xmodule.modulestore.locator import BlockUsageLocator ...@@ -22,6 +21,8 @@ from xmodule.modulestore.locator import BlockUsageLocator
__all__ = ['checklists_handler'] __all__ = ['checklists_handler']
# pylint: disable=unused-argument
@require_http_methods(("GET", "POST", "PUT")) @require_http_methods(("GET", "POST", "PUT"))
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -85,8 +86,8 @@ def checklists_handler(request, tag=None, course_id=None, branch=None, version_g ...@@ -85,8 +86,8 @@ def checklists_handler(request, tag=None, course_id=None, branch=None, version_g
return JsonResponse(expanded_checklist) return JsonResponse(expanded_checklist)
else: else:
return HttpResponseBadRequest( return HttpResponseBadRequest(
( "Could not save checklist state because the checklist index " ("Could not save checklist state because the checklist index "
"was out of range or unspecified."), "was out of range or unspecified."),
content_type="text/plain" content_type="text/plain"
) )
else: else:
...@@ -113,14 +114,12 @@ def expand_checklist_action_url(course_module, checklist): ...@@ -113,14 +114,12 @@ def expand_checklist_action_url(course_module, checklist):
The method does a copy of the input checklist and does not modify the input argument. The method does a copy of the input checklist and does not modify the input argument.
""" """
expanded_checklist = copy.deepcopy(checklist) expanded_checklist = copy.deepcopy(checklist)
oldurlconf_map = {
"SettingsDetails": "settings_details",
"SettingsGrading": "settings_grading"
}
urlconf_map = { urlconf_map = {
"ManageUsers": "course_team", "ManageUsers": "course_team",
"CourseOutline": "course" "CourseOutline": "course",
"SettingsDetails": "settings/details",
"SettingsGrading": "settings/grading",
} }
for item in expanded_checklist.get('items'): for item in expanded_checklist.get('items'):
...@@ -130,12 +129,5 @@ def expand_checklist_action_url(course_module, checklist): ...@@ -130,12 +129,5 @@ def expand_checklist_action_url(course_module, checklist):
ctx_loc = course_module.location ctx_loc = course_module.location
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
item['action_url'] = location.url_reverse(url_prefix, '') item['action_url'] = location.url_reverse(url_prefix, '')
elif action_url in oldurlconf_map:
urlconf_name = oldurlconf_map[action_url]
item['action_url'] = reverse(urlconf_name, kwargs={
'org': course_module.location.org,
'course': course_module.location.course,
'name': course_module.location.name,
})
return expanded_checklist return expanded_checklist
import logging import logging
import sys
from functools import partial from functools import partial
from django.conf import settings from django.conf import settings
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponseBadRequest
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
from xmodule_modifiers import replace_static_urls, wrap_xblock from xmodule_modifiers import replace_static_urls, wrap_xblock
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.x_module import ModuleSystem from xmodule.x_module import ModuleSystem
from xblock.runtime import DbModel from xblock.runtime import DbModel
from xblock.django.request import webob_to_django_response, django_to_webob_request
from xblock.exceptions import NoSuchHandlerError
from lms.xblock.field_data import LmsFieldData from lms.lib.xblock.field_data import LmsFieldData
from lms.lib.xblock.runtime import quote_slashes, unquote_slashes
from util.sandboxing import can_execute_unsafe_code from util.sandboxing import can_execute_unsafe_code
import static_replace import static_replace
from .session_kv_store import SessionKeyValueStore from .session_kv_store import SessionKeyValueStore
from .helpers import render_from_lms from .helpers import render_from_lms
from .access import has_access
from ..utils import get_course_for_item from ..utils import get_course_for_item
__all__ = ['preview_dispatch', 'preview_component'] __all__ = ['preview_handler']
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@login_required def handler_prefix(block, handler='', suffix=''):
def preview_dispatch(request, preview_id, location, dispatch=None): """
Return a url prefix for XBlock handler_url. The full handler_url
should be '{prefix}/{handler}/{suffix}?{query}'.
Trailing `/`s are removed from the returned url.
""" """
Dispatch an AJAX action to a preview XModule return reverse('preview_handler', kwargs={
'usage_id': quote_slashes(str(block.scope_ids.usage_id)),
'handler': handler,
'suffix': suffix,
}).rstrip('/?')
Expects a POST request, and passes the arguments to the module
preview_id (str): An identifier specifying which preview this module is used for @login_required
location: The Location of the module to dispatch to def preview_handler(request, usage_id, handler, suffix=''):
dispatch: The action to execute
""" """
Dispatch an AJAX action to an xblock
usage_id: The usage-id of the block to dispatch to, passed through `quote_slashes`
handler: The handler to execute
suffix: The remainder of the url to be passed to the handler
"""
location = unquote_slashes(usage_id)
descriptor = modulestore().get_item(location) descriptor = modulestore().get_item(location)
instance = load_preview_module(request, preview_id, descriptor) instance = _load_preview_module(request, descriptor)
# Let the module handle the AJAX # Let the module handle the AJAX
req = django_to_webob_request(request)
try: try:
ajax_return = instance.handle_ajax(dispatch, request.POST) resp = instance.handle(handler, req, suffix)
# Save any module data that has changed to the underlying KeyValueStore
instance.save() except NoSuchHandlerError:
log.exception("XBlock %s attempted to access missing handler %r", instance, handler)
raise Http404
except NotFoundError: except NotFoundError:
log.exception("Module indicating to user that request doesn't exist") log.exception("Module indicating to user that request doesn't exist")
...@@ -60,58 +77,38 @@ def preview_dispatch(request, preview_id, location, dispatch=None): ...@@ -60,58 +77,38 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
exc_info=True) exc_info=True)
return HttpResponseBadRequest() return HttpResponseBadRequest()
except: except Exception:
log.exception("error processing ajax call") log.exception("error processing ajax call")
raise raise
return HttpResponse(ajax_return) return webob_to_django_response(resp)
@login_required class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
def preview_component(request, location): """
"Return the HTML preview of a component" An XModule ModuleSystem for use in Studio previews
# TODO (vshnayder): change name from id to location in coffee+html as well. """
if not has_access(request.user, location): def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False):
return HttpResponseForbidden() return handler_prefix(block, handler_name, suffix) + '?' + query
component = modulestore().get_item(location)
# Wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
component.runtime.wrappers.append(wrap_xblock)
try:
content = component.render('studio_view').content
# catch exceptions indiscriminately, since after this point they escape the
# dungeon and surface as uneditable, unsaveable, and undeletable
# component-goblins.
except Exception as exc: # pylint: disable=W0703
content = render_to_string('html_error.html', {'message': str(exc)})
return render_to_response('component.html', {
'preview': get_preview_html(request, component, 0),
'editor': content
})
def preview_module_system(request, preview_id, descriptor): def _preview_module_system(request, descriptor):
""" """
Returns a ModuleSystem for the specified descriptor that is specialized for Returns a ModuleSystem for the specified descriptor that is specialized for
rendering module previews. rendering module previews.
request: The active django request request: The active django request
preview_id (str): An identifier specifying which preview this module is used for
descriptor: An XModuleDescriptor descriptor: An XModuleDescriptor
""" """
course_id = get_course_for_item(descriptor.location).location.course_id course_id = get_course_for_item(descriptor.location).location.course_id
return ModuleSystem( return PreviewModuleSystem(
static_url=settings.STATIC_URL, static_url=settings.STATIC_URL,
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
# TODO (cpennington): Do we want to track how instructors are using the preview problems? # TODO (cpennington): Do we want to track how instructors are using the preview problems?
track_function=lambda event_type, event: None, track_function=lambda event_type, event: None,
filestore=descriptor.runtime.resources_fs, filestore=descriptor.runtime.resources_fs,
get_module=partial(load_preview_module, request, preview_id), get_module=partial(_load_preview_module, request),
render_template=render_from_lms, render_template=render_from_lms,
debug=True, debug=True,
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id), replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id),
...@@ -124,7 +121,7 @@ def preview_module_system(request, preview_id, descriptor): ...@@ -124,7 +121,7 @@ def preview_module_system(request, preview_id, descriptor):
# Set up functions to modify the fragment produced by student_view # Set up functions to modify the fragment produced by student_view
wrappers=( wrappers=(
# This wrapper wraps the module in the template specified above # This wrapper wraps the module in the template specified above
partial(wrap_xblock, display_name_only=descriptor.location.category == 'static_tab'), partial(wrap_xblock, handler_prefix, display_name_only=descriptor.location.category == 'static_tab'),
# This wrapper replaces urls in the output that start with /static # This wrapper replaces urls in the output that start with /static
# with the correct course-specific url for the static content # with the correct course-specific url for the static content
...@@ -138,28 +135,27 @@ def preview_module_system(request, preview_id, descriptor): ...@@ -138,28 +135,27 @@ def preview_module_system(request, preview_id, descriptor):
) )
def load_preview_module(request, preview_id, descriptor): def _load_preview_module(request, descriptor):
""" """
Return a preview XModule instantiated from the supplied descriptor. Return a preview XModule instantiated from the supplied descriptor.
request: The active django request request: The active django request
preview_id (str): An identifier specifying which preview this module is used for
descriptor: An XModuleDescriptor descriptor: An XModuleDescriptor
""" """
student_data = DbModel(SessionKeyValueStore(request)) student_data = DbModel(SessionKeyValueStore(request))
descriptor.bind_for_student( descriptor.bind_for_student(
preview_module_system(request, preview_id, descriptor), _preview_module_system(request, descriptor),
LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access
) )
return descriptor return descriptor
def get_preview_html(request, descriptor, idx): def get_preview_html(request, descriptor):
""" """
Returns the HTML returned by the XModule's student_view, Returns the HTML returned by the XModule's student_view,
specified by the descriptor and idx. specified by the descriptor and idx.
""" """
module = load_preview_module(request, str(idx), descriptor) module = _load_preview_module(request, descriptor)
try: try:
content = module.render("student_view").content content = module.render("student_view").content
except Exception as exc: # pylint: disable=W0703 except Exception as exc: # pylint: disable=W0703
......
...@@ -9,9 +9,8 @@ from django.conf import settings ...@@ -9,9 +9,8 @@ from django.conf import settings
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from external_auth.views import ssl_login_shortcut from external_auth.views import ssl_login_shortcut
from .user import index
__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks'] __all__ = ['signup', 'login_page', 'howitworks']
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -23,13 +22,6 @@ def signup(request): ...@@ -23,13 +22,6 @@ def signup(request):
return render_to_response('signup.html', {'csrf': csrf_token}) return render_to_response('signup.html', {'csrf': csrf_token})
def old_login_redirect(request):
'''
Redirect to the active login url.
'''
return redirect('login', permanent=True)
@ssl_login_shortcut @ssl_login_shortcut
@ensure_csrf_cookie @ensure_csrf_cookie
def login_page(request): def login_page(request):
...@@ -39,13 +31,13 @@ def login_page(request): ...@@ -39,13 +31,13 @@ def login_page(request):
csrf_token = csrf(request)['csrf_token'] csrf_token = csrf(request)['csrf_token']
return render_to_response('login.html', { return render_to_response('login.html', {
'csrf': csrf_token, 'csrf': csrf_token,
'forgot_password_link': "//{base}/#forgot-password-modal".format(base=settings.LMS_BASE), 'forgot_password_link': "//{base}/login#forgot-password-modal".format(base=settings.LMS_BASE),
}) })
def howitworks(request): def howitworks(request):
"Proxy view" "Proxy view"
if request.user.is_authenticated(): if request.user.is_authenticated():
return index(request) return redirect('/course')
else: else:
return render_to_response('howitworks.html', {}) return render_to_response('howitworks.html', {})
...@@ -18,11 +18,12 @@ from django.conf import settings ...@@ -18,11 +18,12 @@ from django.conf import settings
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore, loc_mapper
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError
from util.json_request import JsonResponse from util.json_request import JsonResponse
from xmodule.modulestore.locator import BlockUsageLocator
from ..transcripts_utils import ( from ..transcripts_utils import (
generate_subs_from_source, generate_subs_from_source,
...@@ -77,20 +78,14 @@ def upload_transcripts(request): ...@@ -77,20 +78,14 @@ def upload_transcripts(request):
'subs': '', 'subs': '',
} }
item_location = request.POST.get('id') locator = request.POST.get('locator')
if not item_location: if not locator:
return error_response(response, 'POST data without "id" form data.') return error_response(response, 'POST data without "locator" form data.')
# This is placed before has_access() to validate item_location,
# because has_access() raises InvalidLocationError if location is invalid.
try: try:
item = modulestore().get_item(item_location) item = _get_item(request, request.POST)
except (ItemNotFoundError, InvalidLocationError): except (ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError):
return error_response(response, "Can't find item by location.") return error_response(response, "Can't find item by locator.")
# Check permissions for this user within this course.
if not has_access(request.user, item_location):
raise PermissionDenied()
if 'file' not in request.FILES: if 'file' not in request.FILES:
return error_response(response, 'POST data without "file" form data.') return error_response(response, 'POST data without "file" form data.')
...@@ -156,23 +151,17 @@ def download_transcripts(request): ...@@ -156,23 +151,17 @@ def download_transcripts(request):
Raises Http404 if unsuccessful. Raises Http404 if unsuccessful.
""" """
item_location = request.GET.get('id') locator = request.GET.get('locator')
if not item_location: if not locator:
log.debug('GET data without "id" property.') log.debug('GET data without "locator" property.')
raise Http404 raise Http404
# This is placed before has_access() to validate item_location,
# because has_access() raises InvalidLocationError if location is invalid.
try: try:
item = modulestore().get_item(item_location) item = _get_item(request, request.GET)
except (ItemNotFoundError, InvalidLocationError): except (ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError):
log.debug("Can't find item by location.") log.debug("Can't find item by locator.")
raise Http404 raise Http404
# Check permissions for this user within this course.
if not has_access(request.user, item_location):
raise PermissionDenied()
subs_id = request.GET.get('subs_id') subs_id = request.GET.get('subs_id')
if not subs_id: if not subs_id:
log.debug('GET data without "subs_id" property.') log.debug('GET data without "subs_id" property.')
...@@ -240,7 +229,7 @@ def check_transcripts(request): ...@@ -240,7 +229,7 @@ def check_transcripts(request):
'status': 'Error', 'status': 'Error',
} }
try: try:
__, videos, item = validate_transcripts_data(request) __, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e: except TranscriptsRequestValidationException as e:
return error_response(transcripts_presence, e.message) return error_response(transcripts_presence, e.message)
...@@ -303,7 +292,7 @@ def check_transcripts(request): ...@@ -303,7 +292,7 @@ def check_transcripts(request):
if len(html5_subs) == 2: # check html5 transcripts for equality if len(html5_subs) == 2: # check html5 transcripts for equality
transcripts_presence['html5_equal'] = json.loads(html5_subs[0]) == json.loads(html5_subs[1]) transcripts_presence['html5_equal'] = json.loads(html5_subs[0]) == json.loads(html5_subs[1])
command, subs_to_use = transcripts_logic(transcripts_presence, videos) command, subs_to_use = _transcripts_logic(transcripts_presence, videos)
transcripts_presence.update({ transcripts_presence.update({
'command': command, 'command': command,
'subs': subs_to_use, 'subs': subs_to_use,
...@@ -311,7 +300,7 @@ def check_transcripts(request): ...@@ -311,7 +300,7 @@ def check_transcripts(request):
return JsonResponse(transcripts_presence) return JsonResponse(transcripts_presence)
def transcripts_logic(transcripts_presence, videos): def _transcripts_logic(transcripts_presence, videos):
""" """
By `transcripts_presence` content, figure what show to user: By `transcripts_presence` content, figure what show to user:
...@@ -386,7 +375,7 @@ def choose_transcripts(request): ...@@ -386,7 +375,7 @@ def choose_transcripts(request):
} }
try: try:
data, videos, item = validate_transcripts_data(request) data, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e: except TranscriptsRequestValidationException as e:
return error_response(response, e.message) return error_response(response, e.message)
...@@ -416,7 +405,7 @@ def replace_transcripts(request): ...@@ -416,7 +405,7 @@ def replace_transcripts(request):
response = {'status': 'Error', 'subs': ''} response = {'status': 'Error', 'subs': ''}
try: try:
__, videos, item = validate_transcripts_data(request) __, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e: except TranscriptsRequestValidationException as e:
return error_response(response, e.message) return error_response(response, e.message)
...@@ -435,7 +424,7 @@ def replace_transcripts(request): ...@@ -435,7 +424,7 @@ def replace_transcripts(request):
return JsonResponse(response) return JsonResponse(response)
def validate_transcripts_data(request): def _validate_transcripts_data(request):
""" """
Validates, that request contains all proper data for transcripts processing. Validates, that request contains all proper data for transcripts processing.
...@@ -452,18 +441,10 @@ def validate_transcripts_data(request): ...@@ -452,18 +441,10 @@ def validate_transcripts_data(request):
if not data: if not data:
raise TranscriptsRequestValidationException('Incoming video data is empty.') raise TranscriptsRequestValidationException('Incoming video data is empty.')
item_location = data.get('id')
# This is placed before has_access() to validate item_location,
# because has_access() raises InvalidLocationError if location is invalid.
try: try:
item = modulestore().get_item(item_location) item = _get_item(request, data)
except (ItemNotFoundError, InvalidLocationError): except (ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError):
raise TranscriptsRequestValidationException("Can't find item by location.") raise TranscriptsRequestValidationException("Can't find item by locator.")
# Check permissions for this user within this course.
if not has_access(request.user, item_location):
raise PermissionDenied()
if item.category != 'video': if item.category != 'video':
raise TranscriptsRequestValidationException('Transcripts are supported only for "video" modules.') raise TranscriptsRequestValidationException('Transcripts are supported only for "video" modules.')
...@@ -492,7 +473,7 @@ def rename_transcripts(request): ...@@ -492,7 +473,7 @@ def rename_transcripts(request):
response = {'status': 'Error', 'subs': ''} response = {'status': 'Error', 'subs': ''}
try: try:
__, videos, item = validate_transcripts_data(request) __, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e: except TranscriptsRequestValidationException as e:
return error_response(response, e.message) return error_response(response, e.message)
...@@ -525,11 +506,10 @@ def save_transcripts(request): ...@@ -525,11 +506,10 @@ def save_transcripts(request):
if not data: if not data:
return error_response(response, 'Incoming video data is empty.') return error_response(response, 'Incoming video data is empty.')
item_location = data.get('id')
try: try:
item = modulestore().get_item(item_location) item = _get_item(request, data)
except (ItemNotFoundError, InvalidLocationError): except (ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError):
return error_response(response, "Can't find item by location.") return error_response(response, "Can't find item by locator.")
metadata = data.get('metadata') metadata = data.get('metadata')
if metadata is not None: if metadata is not None:
...@@ -553,3 +533,24 @@ def save_transcripts(request): ...@@ -553,3 +533,24 @@ def save_transcripts(request):
response['status'] = 'Success' response['status'] = 'Success'
return JsonResponse(response) return JsonResponse(response)
def _get_item(request, data):
"""
Obtains from 'data' the locator for an item.
Next, gets that item from the modulestore (allowing any errors to raise up).
Finally, verifies that the user has access to the item.
Returns the item.
"""
locator = BlockUsageLocator(data.get('locator'))
old_location = loc_mapper().translate_locator_to_location(locator)
# This is placed before has_access() to validate the location,
# because has_access() raises InvalidLocationError if location is invalid.
item = modulestore().get_item(old_location)
if not has_access(request.user, locator):
raise PermissionDenied()
return item
import json import json
from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
...@@ -9,17 +7,14 @@ from django.utils.translation import ugettext as _ ...@@ -9,17 +7,14 @@ from django.utils.translation import ugettext as _
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from django.core.context_processors import csrf
from xmodule.modulestore.django import modulestore, loc_mapper from xmodule.modulestore.django import modulestore, loc_mapper
from xmodule.error_module import ErrorDescriptor from util.json_request import JsonResponse, expect_json
from contentstore.utils import get_lms_link_for_item
from util.json_request import JsonResponse
from auth.authz import ( from auth.authz import (
STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_course_groupname_for_role) STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_course_groupname_for_role,
from course_creators.views import ( get_course_role_users
get_course_creator_status, add_user_with_status_unrequested, )
user_requested_access) from course_creators.views import user_requested_access
from .access import has_access from .access import has_access
...@@ -28,51 +23,7 @@ from xmodule.modulestore.locator import BlockUsageLocator ...@@ -28,51 +23,7 @@ from xmodule.modulestore.locator import BlockUsageLocator
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
__all__ = ['index', 'request_course_creator', 'course_team_handler'] __all__ = ['request_course_creator', 'course_team_handler']
@login_required
@ensure_csrf_cookie
def index(request):
"""
List all courses available to the logged in user
"""
courses = modulestore('direct').get_items(['i4x', None, None, 'course', None])
# filter out courses that we don't have access too
def course_filter(course):
return (has_access(request.user, course.location)
# TODO remove this condition when templates purged from db
and course.location.course != 'templates'
and course.location.org != ''
and course.location.course != ''
and course.location.name != '')
courses = filter(course_filter, courses)
def format_course_for_view(course):
# published = false b/c studio manipulates draft versions not b/c the course isn't pub'd
course_loc = loc_mapper().translate_location(
course.location.course_id, course.location, published=False, add_entry_if_missing=True
)
return (
course.display_name,
# note, couldn't get django reverse to work; so, wrote workaround
course_loc.url_reverse('course/', ''),
get_lms_link_for_item(
course.location
),
course.display_org_with_default,
course.display_number_with_default,
course.location.name
)
return render_to_response('index.html', {
'courses': [format_course_for_view(c) for c in courses if not isinstance(c, ErrorDescriptor)],
'user': request.user,
'request_course_creator_url': reverse('contentstore.views.request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user),
'csrf': csrf(request)['csrf_token']
})
@require_POST @require_POST
...@@ -85,6 +36,7 @@ def request_course_creator(request): ...@@ -85,6 +36,7 @@ def request_course_creator(request):
return JsonResponse({"Status": "OK"}) return JsonResponse({"Status": "OK"})
# pylint: disable=unused-argument
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
@require_http_methods(("GET", "POST", "PUT", "DELETE")) @require_http_methods(("GET", "POST", "PUT", "DELETE"))
...@@ -112,38 +64,39 @@ def course_team_handler(request, tag=None, course_id=None, branch=None, version_ ...@@ -112,38 +64,39 @@ def course_team_handler(request, tag=None, course_id=None, branch=None, version_
return HttpResponseNotFound() return HttpResponseNotFound()
def _manage_users(request, location): def _manage_users(request, locator):
""" """
This view will return all CMS users who are editors for the specified course This view will return all CMS users who are editors for the specified course
""" """
old_location = loc_mapper().translate_locator_to_location(location) old_location = loc_mapper().translate_locator_to_location(locator)
# check that logged in user has permissions to this item # check that logged in user has permissions to this item
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME): if not has_access(request.user, locator):
raise PermissionDenied() raise PermissionDenied()
course_module = modulestore().get_item(old_location) course_module = modulestore().get_item(old_location)
instructors = get_course_role_users(locator, INSTRUCTOR_ROLE_NAME)
staff_groupname = get_course_groupname_for_role(location, "staff") # the page only lists staff and assumes they're a superset of instructors. Do a union to ensure.
staff_group, __ = Group.objects.get_or_create(name=staff_groupname) staff = set(get_course_role_users(locator, STAFF_ROLE_NAME)).union(instructors)
inst_groupname = get_course_groupname_for_role(location, "instructor")
inst_group, __ = Group.objects.get_or_create(name=inst_groupname)
return render_to_response('manage_users.html', { return render_to_response('manage_users.html', {
'context_course': course_module, 'context_course': course_module,
'staff': staff_group.user_set.all(), 'staff': staff,
'instructors': inst_group.user_set.all(), 'instructors': instructors,
'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME), 'allow_actions': has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME),
}) })
def _course_team_user(request, location, email): @expect_json
old_location = loc_mapper().translate_locator_to_location(location) def _course_team_user(request, locator, email):
"""
Handle the add, remove, promote, demote requests ensuring the requester has authority
"""
# check that logged in user has permissions to this item # check that logged in user has permissions to this item
if has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): if has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME):
# instructors have full permissions # instructors have full permissions
pass pass
elif has_access(request.user, location, role=STAFF_ROLE_NAME) and email == request.user.email: elif has_access(request.user, locator, role=STAFF_ROLE_NAME) and email == request.user.email:
# staff can only affect themselves # staff can only affect themselves
pass pass
else: else:
...@@ -173,7 +126,7 @@ def _course_team_user(request, location, email): ...@@ -173,7 +126,7 @@ def _course_team_user(request, location, email):
# what's the highest role that this user has? # what's the highest role that this user has?
groupnames = set(g.name for g in user.groups.all()) groupnames = set(g.name for g in user.groups.all())
for role in roles: for role in roles:
role_groupname = get_course_groupname_for_role(old_location, role) role_groupname = get_course_groupname_for_role(locator, role)
if role_groupname in groupnames: if role_groupname in groupnames:
msg["role"] = role msg["role"] = role
break break
...@@ -189,7 +142,7 @@ def _course_team_user(request, location, email): ...@@ -189,7 +142,7 @@ def _course_team_user(request, location, email):
# make sure that the role groups exist # make sure that the role groups exist
groups = {} groups = {}
for role in roles: for role in roles:
groupname = get_course_groupname_for_role(old_location, role) groupname = get_course_groupname_for_role(locator, role)
group, __ = Group.objects.get_or_create(name=groupname) group, __ = Group.objects.get_or_create(name=groupname)
groups[role] = group groups[role] = group
...@@ -212,22 +165,13 @@ def _course_team_user(request, location, email): ...@@ -212,22 +165,13 @@ def _course_team_user(request, location, email):
return JsonResponse() return JsonResponse()
# all other operations require the requesting user to specify a role # all other operations require the requesting user to specify a role
if request.META.get("CONTENT_TYPE", "").startswith("application/json") and request.body: role = request.json.get("role", request.POST.get("role"))
try: if role is None:
payload = json.loads(request.body) return JsonResponse({"error": _("`role` is required")}, 400)
except:
return JsonResponse({"error": _("malformed JSON")}, 400)
try:
role = payload["role"]
except KeyError:
return JsonResponse({"error": _("`role` is required")}, 400)
else:
if not "role" in request.POST:
return JsonResponse({"error": _("`role` is required")}, 400)
role = request.POST["role"]
old_location = loc_mapper().translate_locator_to_location(locator)
if role == "instructor": if role == "instructor":
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): if not has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME):
msg = { msg = {
"error": _("Only instructors may create other instructors") "error": _("Only instructors may create other instructors")
} }
...@@ -253,28 +197,3 @@ def _course_team_user(request, location, email): ...@@ -253,28 +197,3 @@ def _course_team_user(request, location, email):
CourseEnrollment.enroll(user, old_location.course_id) CourseEnrollment.enroll(user, old_location.course_id)
return JsonResponse() return JsonResponse()
def _get_course_creator_status(user):
"""
Helper method for returning the course creator status for a particular user,
taking into account the values of DISABLE_COURSE_CREATION and ENABLE_CREATOR_GROUP.
If the user passed in has not previously visited the index page, it will be
added with status 'unrequested' if the course creator group is in use.
"""
if user.is_staff:
course_creator_status = 'granted'
elif settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False):
course_creator_status = 'disallowed_for_this_site'
elif settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False):
course_creator_status = get_course_creator_status(user)
if course_creator_status is None:
# User not grandfathered in as an existing user, has not previously visited the dashboard page.
# Add the user to the course creator admin table with status 'unrequested'.
add_user_with_status_unrequested(user)
course_creator_status = get_course_creator_status(user)
else:
course_creator_status = 'granted'
return course_creator_status
import re
import logging
import datetime
import json
from json.encoder import JSONEncoder
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
import json
from json.encoder import JSONEncoder
from contentstore.utils import get_modulestore, course_image_url from contentstore.utils import get_modulestore, course_image_url
from models.settings import course_grading from models.settings import course_grading
from contentstore.utils import update_item from contentstore.utils import update_item
from xmodule.fields import Date from xmodule.fields import Date
import re from xmodule.modulestore.django import loc_mapper
import logging
import datetime
class CourseDetails(object): class CourseDetails(object):
def __init__(self, location): def __init__(self, org, course_id, run):
self.course_location = location # a Location obj # still need these for now b/c the client's screen shows these 3 fields
self.org = org
self.course_id = course_id
self.run = run
self.start_date = None # 'start' self.start_date = None # 'start'
self.end_date = None # 'end' self.end_date = None # 'end'
self.enrollment_start = None self.enrollment_start = None
...@@ -27,16 +32,13 @@ class CourseDetails(object): ...@@ -27,16 +32,13 @@ class CourseDetails(object):
self.course_image_asset_path = "" # URL of the course image self.course_image_asset_path = "" # URL of the course image
@classmethod @classmethod
def fetch(cls, course_location): def fetch(cls, course_locator):
""" """
Fetch the course details for the given course from persistence and return a CourseDetails model. Fetch the course details for the given course from persistence and return a CourseDetails model.
""" """
if not isinstance(course_location, Location): course_old_location = loc_mapper().translate_locator_to_location(course_locator)
course_location = Location(course_location) descriptor = get_modulestore(course_old_location).get_item(course_old_location)
course = cls(course_old_location.org, course_old_location.course, course_old_location.name)
course = cls(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
course.start_date = descriptor.start course.start_date = descriptor.start
course.end_date = descriptor.end course.end_date = descriptor.end
...@@ -45,7 +47,7 @@ class CourseDetails(object): ...@@ -45,7 +47,7 @@ class CourseDetails(object):
course.course_image_name = descriptor.course_image course.course_image_name = descriptor.course_image
course.course_image_asset_path = course_image_url(descriptor) course.course_image_asset_path = course_image_url(descriptor)
temploc = course_location.replace(category='about', name='syllabus') temploc = course_old_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:
...@@ -73,14 +75,12 @@ class CourseDetails(object): ...@@ -73,14 +75,12 @@ class CourseDetails(object):
return course return course
@classmethod @classmethod
def update_from_json(cls, jsondict): def update_from_json(cls, course_locator, jsondict):
""" """
Decode the json into CourseDetails and save any changed attrs to the db Decode the json into CourseDetails and save any changed attrs to the db
""" """
# TODO make it an error for this to be undefined & for it to not be retrievable from modulestore course_old_location = loc_mapper().translate_locator_to_location(course_locator)
course_location = Location(jsondict['course_location']) descriptor = get_modulestore(course_old_location).get_item(course_old_location)
# Will probably want to cache the inflight courses because every blur generates an update
descriptor = get_modulestore(course_location).get_item(course_location)
dirty = False dirty = False
...@@ -134,11 +134,11 @@ class CourseDetails(object): ...@@ -134,11 +134,11 @@ class CourseDetails(object):
# MongoKeyValueStore before we update the mongo datastore. # MongoKeyValueStore before we update the mongo datastore.
descriptor.save() descriptor.save()
get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) get_modulestore(course_old_location).update_metadata(course_old_location, own_metadata(descriptor))
# 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_old_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')
...@@ -151,9 +151,9 @@ class CourseDetails(object): ...@@ -151,9 +151,9 @@ class CourseDetails(object):
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)
# Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm # Could just return jsondict w/o doing any db reads, but I put the reads in as a means to confirm
# it persisted correctly # it persisted correctly
return CourseDetails.fetch(course_location) return CourseDetails.fetch(course_locator)
@staticmethod @staticmethod
def parse_video_tag(raw_video): def parse_video_tag(raw_video):
...@@ -188,6 +188,9 @@ class CourseDetails(object): ...@@ -188,6 +188,9 @@ class CourseDetails(object):
# TODO move to a more general util? # TODO move to a more general util?
class CourseSettingsEncoder(json.JSONEncoder): class CourseSettingsEncoder(json.JSONEncoder):
"""
Serialize CourseDetails, CourseGradingModel, datetime, and old Locations
"""
def default(self, obj): def default(self, obj):
if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)): if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)):
return obj.__dict__ return obj.__dict__
......
from xmodule.modulestore import Location from xblock.fields import Scope
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xblock.fields import Scope
from cms.xmodule_namespace import CmsBlockMixin from cms.xmodule_namespace import CmsBlockMixin
...@@ -20,21 +20,18 @@ class CourseMetadata(object): ...@@ -20,21 +20,18 @@ class CourseMetadata(object):
'tabs', 'tabs',
'graceperiod', 'graceperiod',
'checklists', 'checklists',
'show_timezone' 'show_timezone',
'format',
'graded',
] ]
@classmethod @classmethod
def fetch(cls, course_location): def fetch(cls, descriptor):
""" """
Fetch the key:value editable course details for the given course from Fetch the key:value editable course details for the given course from
persistence and return a CourseMetadata model. persistence and return a CourseMetadata model.
""" """
if not isinstance(course_location, Location): result = {}
course_location = Location(course_location)
course = {}
descriptor = get_modulestore(course_location).get_item(course_location)
for field in descriptor.fields.values(): for field in descriptor.fields.values():
if field.name in CmsBlockMixin.fields: if field.name in CmsBlockMixin.fields:
...@@ -46,19 +43,17 @@ class CourseMetadata(object): ...@@ -46,19 +43,17 @@ class CourseMetadata(object):
if field.name in cls.FILTERED_LIST: if field.name in cls.FILTERED_LIST:
continue continue
course[field.name] = field.read_json(descriptor) result[field.name] = field.read_json(descriptor)
return course return result
@classmethod @classmethod
def update_from_json(cls, course_location, jsondict, filter_tabs=True): def update_from_json(cls, descriptor, jsondict, filter_tabs=True):
""" """
Decode the json into CourseMetadata and save any changed attrs to the db. Decode the json into CourseMetadata and save any changed attrs to the db.
Ensures none of the fields are in the blacklist. Ensures none of the fields are in the blacklist.
""" """
descriptor = get_modulestore(course_location).get_item(course_location)
dirty = False dirty = False
# Copy the filtered list to avoid permanently changing the class attribute. # Copy the filtered list to avoid permanently changing the class attribute.
...@@ -72,39 +67,17 @@ class CourseMetadata(object): ...@@ -72,39 +67,17 @@ class CourseMetadata(object):
if key in filtered_list: if key in filtered_list:
continue continue
if key == "unsetKeys":
dirty = True
for unset in val:
descriptor.fields[unset].delete_from(descriptor)
if hasattr(descriptor, key) and getattr(descriptor, key) != val: if hasattr(descriptor, key) and getattr(descriptor, key) != val:
dirty = True dirty = True
value = descriptor.fields[key].from_json(val) value = descriptor.fields[key].from_json(val)
setattr(descriptor, key, value) setattr(descriptor, key, value)
if dirty: if dirty:
# Save the data that we've just changed to the underlying get_modulestore(descriptor.location).update_metadata(descriptor.location, own_metadata(descriptor))
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_metadata(course_location,
own_metadata(descriptor))
# Could just generate and return a course obj w/o doing any db reads,
# but I put the reads in as a means to confirm it persisted correctly
return cls.fetch(course_location)
@classmethod
def delete_key(cls, course_location, payload):
'''
Remove the given metadata key(s) from the course. payload can be a
single key or [key..]
'''
descriptor = get_modulestore(course_location).get_item(course_location)
for key in payload['deleteKeys']:
if hasattr(descriptor, key):
delattr(descriptor, key)
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_metadata(course_location,
own_metadata(descriptor))
return cls.fetch(course_location) return cls.fetch(descriptor)
...@@ -80,6 +80,12 @@ DATABASES = { ...@@ -80,6 +80,12 @@ DATABASES = {
} }
} }
# Enable asset pipeline
# Our fork of django-pipeline uses `PIPELINE` instead of `PIPELINE_ENABLED`
# PipelineFinder is explained here: http://django-pipeline.readthedocs.org/en/1.1.24/storages.html
PIPELINE = True
STATICFILES_FINDERS += ('pipeline.finders.PipelineFinder', )
# Use the auto_auth workflow for creating users and logging them in # Use the auto_auth workflow for creating users and logging them in
MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
......
...@@ -86,6 +86,16 @@ CELERY_QUEUES = { ...@@ -86,6 +86,16 @@ CELERY_QUEUES = {
with open(CONFIG_ROOT / CONFIG_PREFIX + "env.json") as env_file: with open(CONFIG_ROOT / CONFIG_PREFIX + "env.json") as env_file:
ENV_TOKENS = json.load(env_file) ENV_TOKENS = json.load(env_file)
# STATIC_URL_BASE specifies the base url to use for static files
STATIC_URL_BASE = ENV_TOKENS.get('STATIC_URL_BASE', None)
if STATIC_URL_BASE:
# collectstatic will fail if STATIC_URL is a unicode string
STATIC_URL = STATIC_URL_BASE.encode('ascii') + "/" + git.revision + "/"
# GITHUB_REPO_ROOT is the base directory
# for course data
GITHUB_REPO_ROOT = ENV_TOKENS.get('GITHUB_REPO_ROOT', GITHUB_REPO_ROOT)
# STATIC_ROOT specifies the directory where static files are # STATIC_ROOT specifies the directory where static files are
# collected # collected
...@@ -156,9 +166,14 @@ SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY') ...@@ -156,9 +166,14 @@ SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY')
if SEGMENT_IO_KEY: if SEGMENT_IO_KEY:
MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False) MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False)
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
if AWS_ACCESS_KEY_ID == "":
AWS_ACCESS_KEY_ID = None
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
if AWS_SECRET_ACCESS_KEY == "":
AWS_SECRET_ACCESS_KEY = None
DATABASES = AUTH_TOKENS['DATABASES'] DATABASES = AUTH_TOKENS['DATABASES']
MODULESTORE = AUTH_TOKENS['MODULESTORE'] MODULESTORE = AUTH_TOKENS['MODULESTORE']
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE'] CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
......
...@@ -28,7 +28,7 @@ import lms.envs.common ...@@ -28,7 +28,7 @@ import lms.envs.common
from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL
from path import path from path import path
from lms.xblock.mixin import LmsBlockMixin from lms.lib.xblock.mixin import LmsBlockMixin
from cms.xmodule_namespace import CmsBlockMixin from cms.xmodule_namespace import CmsBlockMixin
from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.x_module import XModuleMixin from xmodule.x_module import XModuleMixin
...@@ -136,6 +136,7 @@ XQUEUE_INTERFACE = { ...@@ -136,6 +136,7 @@ XQUEUE_INTERFACE = {
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'staticfiles.finders.FileSystemFinder', 'staticfiles.finders.FileSystemFinder',
'staticfiles.finders.AppDirectoriesFinder', 'staticfiles.finders.AppDirectoriesFinder',
'pipeline.finders.PipelineFinder',
) )
# List of callables that know how to import templates from various sources. # List of callables that know how to import templates from various sources.
...@@ -156,6 +157,7 @@ MIDDLEWARE_CLASSES = ( ...@@ -156,6 +157,7 @@ MIDDLEWARE_CLASSES = (
'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware', 'cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
'student.middleware.UserStandingMiddleware', 'student.middleware.UserStandingMiddleware',
'contentserver.middleware.StaticContentServer', 'contentserver.middleware.StaticContentServer',
'crum.CurrentRequestUserMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'track.middleware.TrackMiddleware', 'track.middleware.TrackMiddleware',
...@@ -195,9 +197,9 @@ IGNORABLE_404_ENDS = ('favicon.ico') ...@@ -195,9 +197,9 @@ IGNORABLE_404_ENDS = ('favicon.ico')
# Email # Email
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEFAULT_FROM_EMAIL = 'registration@edx.org' DEFAULT_FROM_EMAIL = 'registration@example.com'
DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org' DEFAULT_FEEDBACK_EMAIL = 'feedback@example.com'
SERVER_EMAIL = 'devops@edx.org' SERVER_EMAIL = 'devops@example.com'
ADMINS = () ADMINS = ()
MANAGERS = ADMINS MANAGERS = ADMINS
...@@ -240,16 +242,35 @@ STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage' ...@@ -240,16 +242,35 @@ STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
from rooted_paths import rooted_glob from rooted_paths import rooted_glob
PIPELINE_CSS = { PIPELINE_CSS = {
'base-style': { 'style-vendor': {
'source_filenames': [ 'source_filenames': [
'css/vendor/normalize.css',
'css/vendor/font-awesome.css',
'js/vendor/CodeMirror/codemirror.css', 'js/vendor/CodeMirror/codemirror.css',
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css', 'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
'css/vendor/jquery.qtip.min.css', 'css/vendor/jquery.qtip.min.css',
'sass/base-style.css', 'js/vendor/markitup/skins/simple/style.css',
'xmodule/modules.css', 'js/vendor/markitup/sets/wiki/style.css',
'xmodule/descriptor.css',
], ],
'output_filename': 'css/cms-base-style.css', 'output_filename': 'css/cms-style-vendor.css',
},
'style-app': {
'source_filenames': [
'sass/style-app.css',
],
'output_filename': 'css/cms-style-app.css',
},
'style-app-extend1': {
'source_filenames': [
'sass/style-app-extend1.css',
],
'output_filename': 'css/cms-style-app-extend1.css',
},
'style-xmodule': {
'source_filenames': [
'sass/style-xmodule.css',
],
'output_filename': 'css/cms-style-xmodule.css',
}, },
} }
......
...@@ -10,6 +10,10 @@ from logsettings import get_logger_config ...@@ -10,6 +10,10 @@ from logsettings import get_logger_config
DEBUG = True DEBUG = True
USE_I18N = True USE_I18N = True
# For displaying the dummy text, we need to provide a language mapping.
LANGUAGES = (
('eo', 'Esperanto'),
)
TEMPLATE_DEBUG = DEBUG TEMPLATE_DEBUG = DEBUG
LOGGING = get_logger_config(ENV_ROOT / "log", LOGGING = get_logger_config(ENV_ROOT / "log",
logging_env="dev", logging_env="dev",
......
"""
Specific overrides to the base prod settings to make development easier.
"""
from .aws import * # pylint: disable=wildcard-import, unused-wildcard-import
DEBUG = True
USE_I18N = True
TEMPLATE_DEBUG = DEBUG
################################ LOGGERS ######################################
import logging
# Disable noisy loggers
for pkg_name in ['track.contexts', 'track.middleware', 'dd.dogapi']:
logging.getLogger(pkg_name).setLevel(logging.CRITICAL)
################################ EMAIL ########################################
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
################################# LMS INTEGRATION #############################
LMS_BASE = "localhost:8000"
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview." + LMS_BASE
################################# CELERY ######################################
# By default don't use a worker, execute tasks as if they were local functions
CELERY_ALWAYS_EAGER = True
################################ DEBUG TOOLBAR ################################
INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo')
MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
INTERNAL_IPS = ('127.0.0.1',)
DEBUG_TOOLBAR_PANELS = (
'debug_toolbar.panels.version.VersionDebugPanel',
'debug_toolbar.panels.timer.TimerDebugPanel',
'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel',
'debug_toolbar.panels.headers.HeaderDebugPanel',
'debug_toolbar.panels.request_vars.RequestVarsDebugPanel',
'debug_toolbar.panels.sql.SQLDebugPanel',
'debug_toolbar.panels.signals.SignalDebugPanel',
'debug_toolbar.panels.logger.LoggingPanel',
)
DEBUG_TOOLBAR_CONFIG = {
'INTERCEPT_REDIRECTS': False
}
# To see stacktraces for MongoDB queries, set this to True.
# Stacktraces slow down page loads drastically (for pages with lots of queries).
DEBUG_TOOLBAR_MONGO_STACKTRACES = False
###############################################################################
# Lastly, see if the developer has any local overrides.
try:
from .private import * # pylint: disable=F0401
except ImportError:
pass
...@@ -182,7 +182,7 @@ define([ ...@@ -182,7 +182,7 @@ define([
"coffee/spec/main_spec", "coffee/spec/main_spec",
"coffee/spec/models/course_spec", "coffee/spec/models/metadata_spec", "coffee/spec/models/course_spec", "coffee/spec/models/metadata_spec",
"coffee/spec/models/module_spec", "coffee/spec/models/section_spec", "coffee/spec/models/section_spec",
"coffee/spec/models/settings_course_grader_spec", "coffee/spec/models/settings_course_grader_spec",
"coffee/spec/models/settings_grading_spec", "coffee/spec/models/textbook_spec", "coffee/spec/models/settings_grading_spec", "coffee/spec/models/textbook_spec",
"coffee/spec/models/upload_spec", "coffee/spec/models/upload_spec",
...@@ -193,9 +193,12 @@ define([ ...@@ -193,9 +193,12 @@ define([
"coffee/spec/views/overview_spec", "coffee/spec/views/overview_spec",
"coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec", "coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec",
"js_spec/transcripts/utils_spec", "js_spec/transcripts/editor_spec", "js/spec/transcripts/utils_spec", "js/spec/transcripts/editor_spec",
"js_spec/transcripts/videolist_spec", "js_spec/transcripts/message_manager_spec", "js/spec/transcripts/videolist_spec", "js/spec/transcripts/message_manager_spec",
"js_spec/transcripts/file_uploader_spec" "js/spec/transcripts/file_uploader_spec",
"js/spec/utils/module_spec",
"js/spec/models/explicit_url_spec"
# these tests are run separate in the cms-squire suite, due to process # these tests are run separate in the cms-squire suite, due to process
# isolation issues with Squire.js # isolation issues with Squire.js
......
define ["coffee/src/models/module"], (Module) ->
describe "Module", ->
it "set the correct URL", ->
expect(new Module().url).toEqual("/save_item")
it "set the correct default", ->
expect(new Module().defaults).toEqual(undefined)
define ["js/models/section", "sinon"], (Section, sinon) -> define ["js/models/section", "sinon", "js/utils/module"], (Section, sinon, ModuleUtils) ->
describe "Section", -> describe "Section", ->
describe "basic", -> describe "basic", ->
beforeEach -> beforeEach ->
@model = new Section({ @model = new Section({
id: 42, id: 42
name: "Life, the Universe, and Everything" name: "Life, the Universe, and Everything"
}) })
...@@ -14,11 +14,10 @@ define ["js/models/section", "sinon"], (Section, sinon) -> ...@@ -14,11 +14,10 @@ define ["js/models/section", "sinon"], (Section, sinon) ->
expect(@model.get("name")).toEqual("Life, the Universe, and Everything") expect(@model.get("name")).toEqual("Life, the Universe, and Everything")
it "should have a URL set", -> it "should have a URL set", ->
expect(@model.url).toEqual("/save_item") expect(@model.url()).toEqual(ModuleUtils.getUpdateUrl(42))
it "should serialize to JSON correctly", -> it "should serialize to JSON correctly", ->
expect(@model.toJSON()).toEqual({ expect(@model.toJSON()).toEqual({
id: 42,
metadata: metadata:
{ {
display_name: "Life, the Universe, and Everything" display_name: "Life, the Universe, and Everything"
...@@ -30,7 +29,7 @@ define ["js/models/section", "sinon"], (Section, sinon) -> ...@@ -30,7 +29,7 @@ define ["js/models/section", "sinon"], (Section, sinon) ->
spyOn(Section.prototype, 'showNotification') spyOn(Section.prototype, 'showNotification')
spyOn(Section.prototype, 'hideNotification') spyOn(Section.prototype, 'hideNotification')
@model = new Section({ @model = new Section({
id: 42, id: 42
name: "Life, the Universe, and Everything" name: "Life, the Universe, and Everything"
}) })
@requests = requests = [] @requests = requests = []
......
...@@ -34,6 +34,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model ...@@ -34,6 +34,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
@xhrRestore = courseUpdatesXhr.restore @xhrRestore = courseUpdatesXhr.restore
@collection = new CourseUpdateCollection() @collection = new CourseUpdateCollection()
@collection.url = 'course_info_update/'
@courseInfoEdit = new CourseInfoUpdateView({ @courseInfoEdit = new CourseInfoUpdateView({
el: $('.course-updates'), el: $('.course-updates'),
collection: @collection, collection: @collection,
...@@ -195,3 +196,22 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model ...@@ -195,3 +196,22 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
@handoutsEdit.$el.find('.edit-button').click() @handoutsEdit.$el.find('.edit-button').click()
expect(@handoutsEdit.$codeMirror.getValue().trim()).toEqual('/static/fromServer.jpg') expect(@handoutsEdit.$codeMirror.getValue().trim()).toEqual('/static/fromServer.jpg')
it "can open course handouts with bad html on edit", ->
# Enter some bad html in handouts section, verifying that the
# model/handoutform opens when "Edit" is clicked
@model = new ModuleInfo({
id: 'handouts-id',
data: '<p><a href="[URL OF FILE]>[LINK TEXT]</a></p>'
})
@handoutsEdit = new CourseInfoHandoutsView({
el: $('#course-handouts-view'),
model: @model,
base_asset_url: 'base-asset-url/'
});
@handoutsEdit.render()
expect($('.edit-handouts-form').is(':hidden')).toEqual(true)
@handoutsEdit.$el.find('.edit-button').click()
expect(@handoutsEdit.$codeMirror.getValue()).toEqual('<p><a href="[URL OF FILE]>[LINK TEXT]</a></p>')
expect($('.edit-handouts-form').is(':hidden')).toEqual(false)
\ No newline at end of file
define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (ModuleEdit, ModuleModel) ->
describe "ModuleEdit", -> describe "ModuleEdit", ->
beforeEach -> beforeEach ->
@stubModule = jasmine.createSpy("Module") @stubModule = new ModuleModel
@stubModule.id = 'stub-id' id: "stub-id"
setFixtures """ setFixtures """
<li class="component" id="stub-id"> <li class="component" id="stub-id">
...@@ -59,7 +59,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> ...@@ -59,7 +59,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) ->
@moduleEdit.render() @moduleEdit.render()
it "loads the module preview and editor via ajax on the view element", -> it "loads the module preview and editor via ajax on the view element", ->
expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/preview_component/#{@moduleEdit.model.id}", jasmine.any(Function)) expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/xblock/#{@moduleEdit.model.id}", jasmine.any(Function))
@moduleEdit.$el.load.mostRecentCall.args[1]() @moduleEdit.$el.load.mostRecentCall.args[1]()
expect(@moduleEdit.loadDisplay).toHaveBeenCalled() expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
expect(@moduleEdit.delegateEvents).toHaveBeenCalled() expect(@moduleEdit.delegateEvents).toHaveBeenCalled()
......
...@@ -8,7 +8,7 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base ...@@ -8,7 +8,7 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base
<span class="published-status"> <span class="published-status">
<strong>Will Release:</strong> 06/12/2013 at 04:00 UTC <strong>Will Release:</strong> 06/12/2013 at 04:00 UTC
</span> </span>
<a href="#" class="edit-button" data-date="06/12/2013" data-time="04:00" data-id="i4x://pfogg/42/chapter/d6b47f7b084f49debcaf67fe5436c8e2">Edit</a> <a href="#" class="edit-button" data-date="06/12/2013" data-time="04:00" data-locator="i4x://pfogg/42/chapter/d6b47f7b084f49debcaf67fe5436c8e2">Edit</a>
</div> </div>
""" """
...@@ -35,8 +35,8 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base ...@@ -35,8 +35,8 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base
""" """
appendSetFixtures """ appendSetFixtures """
<section class="courseware-section branch" data-id="a-location-goes-here"> <section class="courseware-section branch" data-locator="a-location-goes-here">
<li class="branch collapsed id-holder" data-id="an-id-goes-here"> <li class="branch collapsed id-holder" data-locator="an-id-goes-here">
<a href="#" class="delete-section-button"></a> <a href="#" class="delete-section-button"></a>
</li> </li>
</section> </section>
...@@ -44,19 +44,19 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base ...@@ -44,19 +44,19 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base
appendSetFixtures """ appendSetFixtures """
<ol> <ol>
<li class="subsection-list branch" data-id="subsection-1-id" id="subsection-1"> <li class="subsection-list branch" id="subsection-1" data-locator="subsection-1-id">
<ol class="sortable-unit-list" id="subsection-list-1"> <ol class="sortable-unit-list" id="subsection-list-1">
<li class="unit" id="unit-1" data-id="first-unit-id" data-parent-id="subsection-1-id"></li> <li class="unit" id="unit-1" data-parent="subsection-1-id" data-locator="first-unit-id"></li>
<li class="unit" id="unit-2" data-id="second-unit-id" data-parent-id="subsection-1-id"></li> <li class="unit" id="unit-2" data-parent="subsection-1-id" data-locator="second-unit-id"></li>
<li class="unit" id="unit-3" data-id="third-unit-id" data-parent-id="subsection-1-id"></li> <li class="unit" id="unit-3" data-parent="subsection-1-id" data-locator="third-unit-id"></li>
</ol> </ol>
</li> </li>
<li class="subsection-list branch" data-id="subsection-2-id" id="subsection-2"> <li class="subsection-list branch" id="subsection-2" data-locator="subsection-2-id">
<ol class="sortable-unit-list" id="subsection-list-2"> <ol class="sortable-unit-list" id="subsection-list-2">
<li class="unit" id="unit-4" data-id="fourth-unit-id" data-parent-id="subsection-2"></li> <li class="unit" id="unit-4" data-parent="subsection-2" data-locator="fourth-unit-id"></li>
</ol> </ol>
</li> </li>
<li class="subsection-list branch" data-id="subsection-3-id" id="subsection-3"> <li class="subsection-list branch" id="subsection-3" data-locator="subsection-3-id">
<ol class="sortable-unit-list" id="subsection-list-3"> <ol class="sortable-unit-list" id="subsection-list-3">
</li> </li>
</ol> </ol>
...@@ -366,10 +366,10 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base ...@@ -366,10 +366,10 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base
expect($('#unit-1')).toHaveClass('was-dropped') expect($('#unit-1')).toHaveClass('was-dropped')
# We expect 2 requests to be sent-- the first for removing Unit 1 from Subsection 1, # We expect 2 requests to be sent-- the first for removing Unit 1 from Subsection 1,
# and the second for adding Unit 1 to the end of Subsection 2. # and the second for adding Unit 1 to the end of Subsection 2.
expect(@requests[0].requestBody).toEqual('{"id":"subsection-1-id","children":["second-unit-id","third-unit-id"]}') expect(@requests[0].requestBody).toEqual('{"children":["second-unit-id","third-unit-id"]}')
@requests[0].respond(200) @requests[0].respond(200)
expect(@savingSpies.hide).not.toHaveBeenCalled() expect(@savingSpies.hide).not.toHaveBeenCalled()
expect(@requests[1].requestBody).toEqual('{"id":"subsection-2-id","children":["fourth-unit-id","first-unit-id"]}') expect(@requests[1].requestBody).toEqual('{"children":["fourth-unit-id","first-unit-id"]}')
@requests[1].respond(200) @requests[1].respond(200)
expect(@savingSpies.hide).toHaveBeenCalled() expect(@savingSpies.hide).toHaveBeenCalled()
# Class is removed in a timeout. # Class is removed in a timeout.
......
...@@ -2,17 +2,16 @@ define ["domReady", "jquery", "underscore.string", "backbone", "gettext", ...@@ -2,17 +2,16 @@ define ["domReady", "jquery", "underscore.string", "backbone", "gettext",
"js/views/feedback_notification", "js/views/feedback_notification",
"coffee/src/ajax_prefix", "jquery.cookie"], "coffee/src/ajax_prefix", "jquery.cookie"],
(domReady, $, str, Backbone, gettext, NotificationView) -> (domReady, $, str, Backbone, gettext, NotificationView) ->
AjaxPrefix.addAjaxPrefix jQuery, -> main = ->
$("meta[name='path_prefix']").attr('content') AjaxPrefix.addAjaxPrefix jQuery, ->
$("meta[name='path_prefix']").attr('content')
window.CMS = window.CMS or {}
CMS.URL = CMS.URL or {}
window.onTouchBasedDevice = ->
navigator.userAgent.match /iPhone|iPod|iPad/i
_.extend CMS, Backbone.Events window.CMS = window.CMS or {}
CMS.URL = CMS.URL or {}
window.onTouchBasedDevice = ->
navigator.userAgent.match /iPhone|iPod|iPad/i
main = -> _.extend CMS, Backbone.Events
Backbone.emulateHTTP = true Backbone.emulateHTTP = true
$.ajaxSetup $.ajaxSetup
...@@ -35,8 +34,22 @@ define ["domReady", "jquery", "underscore.string", "backbone", "gettext", ...@@ -35,8 +34,22 @@ define ["domReady", "jquery", "underscore.string", "backbone", "gettext",
) )
msg.show() msg.show()
if onTouchBasedDevice() $.postJSON = (url, data, callback) ->
$('body').addClass 'touch-based-device' # shift arguments if data argument was omitted
if $.isFunction(data)
callback = data
data = `undefined`
$.ajax
url: url
type: "POST"
contentType: "application/json; charset=utf-8"
dataType: "json"
data: JSON.stringify(data)
success: callback
domReady ->
if onTouchBasedDevice()
$('body').addClass 'touch-based-device'
domReady(main) main()
return main return main
define ["backbone"], (Backbone) ->
class Module extends Backbone.Model
url: '/save_item'
...@@ -63,19 +63,19 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", ...@@ -63,19 +63,19 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata()) return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata())
createItem: (parent, payload) -> createItem: (parent, payload) ->
payload.parent_location = parent payload.parent_locator = parent
$.post( $.postJSON(
"/create_item" @model.urlRoot
payload payload
(data) => (data) =>
@model.set(id: data.id) @model.set(id: data.locator)
@$el.data('id', data.id) @$el.data('locator', data.locator)
@render() @render()
) )
render: -> render: ->
if @model.id if @model.id
@$el.load("/preview_component/#{@model.id}", => @$el.load(@model.url(), =>
@loadDisplay() @loadDisplay()
@delegateEvents() @delegateEvents()
) )
......
define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views/feedback_notification", "coffee/src/models/module", "coffee/src/views/module_edit"], define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views/feedback_notification",
($, ui, Backbone, PromptView, NotificationView, ModuleModel, ModuleEditView) -> "coffee/src/views/module_edit", "js/models/module_info", "js/utils/module"],
($, ui, Backbone, PromptView, NotificationView, ModuleEditView, ModuleModel, ModuleUtils) ->
class TabsEdit extends Backbone.View class TabsEdit extends Backbone.View
initialize: => initialize: =>
@$('.component').each((idx, element) => @$('.component').each((idx, element) =>
model = new ModuleModel({
id: $(element).data('locator')
})
new ModuleEditView( new ModuleEditView(
el: element, el: element,
onDelete: @deleteTab, onDelete: @deleteTab,
model: new ModuleModel( model: model
id: $(element).data('id'),
)
) )
) )
...@@ -28,20 +31,23 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views ...@@ -28,20 +31,23 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
tabMoved: (event, ui) => tabMoved: (event, ui) =>
tabs = [] tabs = []
@$('.component').each((idx, element) => @$('.component').each((idx, element) =>
tabs.push($(element).data('id')) tabs.push($(element).data('locator'))
) )
analytics.track "Reordered Static Pages", analytics.track "Reordered Static Pages",
course: course_location_analytics course: course_location_analytics
saving = new NotificationView.Mini({title: gettext("Saving&hellip;")})
saving.show()
$.ajax({ $.ajax({
type:'POST', type:'POST',
url: '/reorder_static_tabs', url: @model.url(),
data: JSON.stringify({ data: JSON.stringify({
tabs : tabs tabs : tabs
}), }),
contentType: 'application/json' contentType: 'application/json'
}) }).success(=> saving.hide())
addNewTab: (event) => addNewTab: (event) =>
event.preventDefault() event.preventDefault()
...@@ -78,13 +84,14 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views ...@@ -78,13 +84,14 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
analytics.track "Deleted Static Page", analytics.track "Deleted Static Page",
course: course_location_analytics course: course_location_analytics
id: $component.data('id') id: $component.data('locator')
deleting = new NotificationView.Mini deleting = new NotificationView.Mini
title: gettext('Deleting&hellip;') title: gettext('Deleting&hellip;')
deleting.show() deleting.show()
$.post('/delete_item', { $.ajax({
id: $component.data('id') type: 'DELETE',
}, => url: ModuleUtils.getUpdateUrl($component.data('locator'))
}).success(=>
$component.remove() $component.remove()
deleting.hide() deleting.hide()
) )
......
define ["jquery", "jquery.ui", "gettext", "backbone", define ["jquery", "jquery.ui", "gettext", "backbone",
"js/views/feedback_notification", "js/views/feedback_prompt", "js/views/feedback_notification", "js/views/feedback_prompt",
"coffee/src/models/module", "coffee/src/views/module_edit"], "coffee/src/views/module_edit", "js/models/module_info"],
($, ui, gettext, Backbone, NotificationView, PromptView, ModuleModel, ModuleEditView) -> ($, ui, gettext, Backbone, NotificationView, PromptView, ModuleEditView, ModuleModel) ->
class UnitEditView extends Backbone.View class UnitEditView extends Backbone.View
events: events:
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates' 'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates'
...@@ -61,11 +61,12 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -61,11 +61,12 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
) )
@$('.component').each (idx, element) => @$('.component').each (idx, element) =>
model = new ModuleModel
id: $(element).data('locator')
new ModuleEditView new ModuleEditView
el: element, el: element,
onDelete: @deleteComponent, onDelete: @deleteComponent,
model: new ModuleModel model: model
id: $(element).data('id')
showComponentTemplates: (event) => showComponentTemplates: (event) =>
event.preventDefault() event.preventDefault()
...@@ -96,7 +97,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -96,7 +97,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@$newComponentItem.before(editor.$el) @$newComponentItem.before(editor.$el)
editor.createItem( editor.createItem(
@$el.data('id'), @$el.data('locator'),
$(event.currentTarget).data() $(event.currentTarget).data()
) )
...@@ -107,7 +108,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -107,7 +108,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@closeNewComponent(event) @closeNewComponent(event)
components: => @$('.component').map((idx, el) -> $(el).data('id')).get() components: => @$('.component').map((idx, el) -> $(el).data('locator')).get()
wait: (value) => wait: (value) =>
@$('.unit-body').toggleClass("waiting", value) @$('.unit-body').toggleClass("waiting", value)
...@@ -134,14 +135,15 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -134,14 +135,15 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
title: gettext('Deleting&hellip;'), title: gettext('Deleting&hellip;'),
deleting.show() deleting.show()
$component = $(event.currentTarget).parents('.component') $component = $(event.currentTarget).parents('.component')
$.post('/delete_item', { $.ajax({
id: $component.data('id') type: 'DELETE',
}, => url: @model.urlRoot + "/" + $component.data('locator')
}).success(=>
deleting.hide() deleting.hide()
analytics.track "Deleted a Component", analytics.track "Deleted a Component",
course: course_location_analytics course: course_location_analytics
unit_id: unit_location_analytics unit_id: unit_location_analytics
id: $component.data('id') id: $component.data('locator')
$component.remove() $component.remove()
# b/c we don't vigilantly keep children up to date # b/c we don't vigilantly keep children up to date
...@@ -162,23 +164,23 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -162,23 +164,23 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
deleteDraft: (event) -> deleteDraft: (event) ->
@wait(true) @wait(true)
$.ajax({
type: 'DELETE',
url: @model.url() + "?" + $.param({recurse: true})
}).success(=>
$.post('/delete_item', { analytics.track "Deleted Draft",
id: @$el.data('id') course: course_location_analytics
delete_children: true unit_id: unit_location_analytics
}, =>
analytics.track "Deleted Draft",
course: course_location_analytics
unit_id: unit_location_analytics
window.location.reload() window.location.reload()
) )
createDraft: (event) -> createDraft: (event) ->
@wait(true) @wait(true)
$.post('/create_draft', { $.postJSON(@model.url(), {
id: @$el.data('id') publish: 'create_draft'
}, => }, =>
analytics.track "Created Draft", analytics.track "Created Draft",
course: course_location_analytics course: course_location_analytics
...@@ -191,8 +193,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -191,8 +193,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@wait(true) @wait(true)
@saveDraft() @saveDraft()
$.post('/publish_draft', { $.postJSON(@model.url(), {
id: @$el.data('id') publish: 'make_public'
}, => }, =>
analytics.track "Published Draft", analytics.track "Published Draft",
course: course_location_analytics course: course_location_analytics
...@@ -203,16 +205,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -203,16 +205,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
setVisibility: (event) -> setVisibility: (event) ->
if @$('.visibility-select').val() == 'private' if @$('.visibility-select').val() == 'private'
target_url = '/unpublish_unit' action = 'make_private'
visibility = "private" visibility = "private"
else else
target_url = '/publish_draft' action = 'make_public'
visibility = "public" visibility = "public"
@wait(true) @wait(true)
$.post(target_url, { $.postJSON(@model.url(), {
id: @$el.data('id') publish: action
}, => }, =>
analytics.track "Set Unit Visibility", analytics.track "Set Unit Visibility",
course: course_location_analytics course: course_location_analytics
......
/* line 12, ../sass/_reset.scss */
html, body, div, span, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
abbr, address, cite, code,
del, dfn, em, img, ins, kbd, q, samp,
small, strong, sub, sup, var,
b, i,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, figcaption, figure,
footer, header, hgroup, menu, nav, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
outline: 0;
vertical-align: baseline;
background: transparent; }
/* line 21, ../sass/_reset.scss */
html, body {
font-size: 100%; }
/* line 26, ../sass/_reset.scss */
article, aside, details, figcaption, figure, footer, header, hgroup, nav, section {
display: block; }
/* line 31, ../sass/_reset.scss */
audio, canvas, video {
display: inline-block; }
/* line 36, ../sass/_reset.scss */
audio:not([controls]) {
display: none; }
/* line 41, ../sass/_reset.scss */
[hidden] {
display: none; }
/* line 47, ../sass/_reset.scss */
html {
font-size: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; }
/* line 54, ../sass/_reset.scss */
html, button, input, select, textarea {
font-family: sans-serif; }
/* line 60, ../sass/_reset.scss */
a:focus {
outline: thin dotted;
outline: 5px auto -webkit-focus-ring-color;
outline-offset: -2px; }
/* line 69, ../sass/_reset.scss */
a:hover, a:active {
outline: 0; }
/* line 75, ../sass/_reset.scss */
abbr[title] {
border-bottom: 1px dotted; }
/* line 80, ../sass/_reset.scss */
b, strong {
font-weight: bold; }
/* line 84, ../sass/_reset.scss */
blockquote {
margin: 1em 40px; }
/* line 89, ../sass/_reset.scss */
dfn {
font-style: italic; }
/* line 94, ../sass/_reset.scss */
mark {
background: #ff0;
color: #000; }
/* line 101, ../sass/_reset.scss */
pre, code, kbd, samp {
font-family: monospace, serif;
_font-family: 'courier new', monospace;
font-size: 1em; }
/* line 108, ../sass/_reset.scss */
pre {
white-space: pre;
white-space: pre-wrap;
word-wrap: break-word; }
/* line 115, ../sass/_reset.scss */
blockquote, q {
quotes: none; }
/* line 117, ../sass/_reset.scss */
blockquote:before, blockquote:after, q:before, q:after {
content: '';
content: none; }
/* line 123, ../sass/_reset.scss */
small {
font-size: 75%; }
/* line 127, ../sass/_reset.scss */
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline; }
/* line 134, ../sass/_reset.scss */
sup {
top: -0.5em; }
/* line 138, ../sass/_reset.scss */
sub {
bottom: -0.25em; }
/* line 143, ../sass/_reset.scss */
nav ul, nav ol {
list-style: none;
list-style-image: none; }
/* line 150, ../sass/_reset.scss */
img {
border: 0;
height: auto;
max-width: 100%;
-ms-interpolation-mode: bicubic; }
/* line 158, ../sass/_reset.scss */
svg:not(:root) {
overflow: hidden; }
/* line 163, ../sass/_reset.scss */
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em; }
/* line 169, ../sass/_reset.scss */
legend {
border: 0;
padding: 0;
white-space: normal; }
/* line 175, ../sass/_reset.scss */
button, input, select, textarea {
font-size: 100%;
margin: 0;
vertical-align: baseline; }
/* line 182, ../sass/_reset.scss */
button, input {
line-height: normal; }
/* line 186, ../sass/_reset.scss */
button, input[type="button"], input[type="reset"], input[type="submit"] {
cursor: pointer;
-webkit-appearance: button; }
/* line 192, ../sass/_reset.scss */
button[disabled], input[disabled] {
cursor: default; }
/* line 196, ../sass/_reset.scss */
input[type="checkbox"], input[type="radio"] {
box-sizing: border-box;
padding: 0; }
/* line 201, ../sass/_reset.scss */
input[type="search"] {
-webkit-appearance: textfield;
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box;
box-sizing: content-box; }
/* line 209, ../sass/_reset.scss */
input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none; }
/* line 215, ../sass/_reset.scss */
button::-moz-focus-inner, input::-moz-focus-inner {
border: 0;
padding: 0; }
/* line 220, ../sass/_reset.scss */
textarea {
overflow: auto;
vertical-align: top; }
/* line 226, ../sass/_reset.scss */
table {
border-collapse: collapse;
border-spacing: 0; }
This source diff could not be displayed because it is too large. You can view the blob instead.
require(["domReady", "jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", require(["domReady", "jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt",
"js/utils/get_date", "jquery.ui", "jquery.leanModal", "jquery.form", "jquery.smoothScroll"], "js/utils/get_date", "js/utils/module", "jquery.ui", "jquery.leanModal", "jquery.form", "jquery.smoothScroll"],
function(domReady, $, _, gettext, NotificationView, PromptView, DateUtils) { function(domReady, $, _, gettext, NotificationView, PromptView, DateUtils, ModuleUtils) {
var $body; var $body;
var $newComponentItem; var $newComponentItem;
...@@ -178,7 +178,7 @@ function saveSubsection() { ...@@ -178,7 +178,7 @@ function saveSubsection() {
$spinner.show(); $spinner.show();
} }
var id = $('.subsection-body').data('id'); var locator = $('.subsection-body').data('locator');
// pull all 'normalized' metadata editable fields on page // pull all 'normalized' metadata editable fields on page
var metadata_fields = $('input[data-metadata-name]'); var metadata_fields = $('input[data-metadata-name]');
...@@ -202,12 +202,11 @@ function saveSubsection() { ...@@ -202,12 +202,11 @@ function saveSubsection() {
}); });
$.ajax({ $.ajax({
url: "/save_item", url: ModuleUtils.getUpdateUrl(locator),
type: "POST", type: "PUT",
dataType: "json", dataType: "json",
contentType: "application/json", contentType: "application/json",
data: JSON.stringify({ data: JSON.stringify({
'id': id,
'metadata': metadata 'metadata': metadata
}), }),
success: function() { success: function() {
...@@ -226,19 +225,19 @@ function createNewUnit(e) { ...@@ -226,19 +225,19 @@ function createNewUnit(e) {
analytics.track('Created a Unit', { analytics.track('Created a Unit', {
'course': course_location_analytics, 'course': course_location_analytics,
'parent_location': parent 'parent_locator': parent
}); });
$.post('/create_item', { $.postJSON(ModuleUtils.getUpdateUrl(), {
'parent_location': parent, 'parent_locator': parent,
'category': category, 'category': category,
'display_name': 'New Unit' 'display_name': 'New Unit'
}, },
function(data) { function(data) {
// redirect to the edit page // redirect to the edit page
window.location = "/edit/" + data['id']; window.location = "/unit/" + data['locator'];
}); });
} }
...@@ -267,11 +266,11 @@ function _deleteItem($el, type) { ...@@ -267,11 +266,11 @@ function _deleteItem($el, type) {
click: function(view) { click: function(view) {
view.hide(); view.hide();
var id = $el.data('id'); var locator = $el.data('locator');
analytics.track('Deleted an Item', { analytics.track('Deleted an Item', {
'course': course_location_analytics, 'course': course_location_analytics,
'id': id 'id': locator
}); });
var deleting = new NotificationView.Mini({ var deleting = new NotificationView.Mini({
...@@ -279,15 +278,14 @@ function _deleteItem($el, type) { ...@@ -279,15 +278,14 @@ function _deleteItem($el, type) {
}); });
deleting.show(); deleting.show();
$.post('/delete_item', $.ajax({
{'id': id, type: 'DELETE',
'delete_children': true, url: ModuleUtils.getUpdateUrl(locator) +'?'+ $.param({recurse: true, all_versions: true}),
'delete_all_versions': true}, success: function () {
function(data) { $el.remove();
$el.remove(); deleting.hide();
deleting.hide(); }
} });
);
} }
}, },
secondary: { secondary: {
......
...@@ -2,10 +2,6 @@ define(["backbone", "js/models/settings/course_grader"], function(Backbone, Cour ...@@ -2,10 +2,6 @@ define(["backbone", "js/models/settings/course_grader"], function(Backbone, Cour
var CourseGraderCollection = Backbone.Collection.extend({ var CourseGraderCollection = Backbone.Collection.extend({
model : CourseGrader, model : CourseGrader,
course_location : null, // must be set to a Location object
url : function() {
return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/settings-grading/' + this.course_location.get('name') + '/';
},
sumWeights : function() { sumWeights : function() {
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0); return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
} }
......
...@@ -4,7 +4,7 @@ define(["backbone", "js/models/course_update"], function(Backbone, CourseUpdateM ...@@ -4,7 +4,7 @@ define(["backbone", "js/models/course_update"], function(Backbone, CourseUpdateM
collection of updates as [{ date : "month day", content : "html"}] collection of updates as [{ date : "month day", content : "html"}]
*/ */
var CourseUpdateCollection = Backbone.Collection.extend({ var CourseUpdateCollection = Backbone.Collection.extend({
url : function() {return this.urlbase + "course_info/updates/";}, // instantiator must set url
model : CourseUpdateModel model : CourseUpdateModel
}); });
......
...@@ -32,7 +32,7 @@ require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"], ...@@ -32,7 +32,7 @@ require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"],
'run': run 'run': run
}); });
$.post('/create_new_course', { $.postJSON('/course', {
'org': org, 'org': org,
'number': number, 'number': number,
'display_name': display_name, 'display_name': display_name,
......
define(["backbone", "underscore", "js/models/location"], function(Backbone, _, Location) { define(["backbone", "underscore"], function(Backbone, _) {
var AssignmentGrade = Backbone.Model.extend({ var AssignmentGrade = Backbone.Model.extend({
defaults : { defaults : {
graderType : null, // the type label (string). May be "Not Graded" which implies None. I'd like to use id but that's ephemeral graderType : null, // the type label (string). May be "Not Graded" which implies None.
location : null // A location object locator : null // locator for the block
}, },
initialize : function(attrs) { idAttribute: 'locator',
if (attrs['assignmentUrl']) { urlRoot : '/xblock/',
this.set('location', new Location(attrs['assignmentUrl'], {parse: true})); url: function() {
} // add ?fields=graderType to the request url (only needed for fetch, but innocuous for others)
}, return Backbone.Model.prototype.url.apply(this) + '?' + $.param({fields: 'graderType'});
parse : function(attrs) {
if (attrs && attrs['location']) {
attrs.location = new Location(attrs['location'], {parse: true});
}
},
urlRoot : function() {
if (this.has('location')) {
var location = this.get('location');
return '/' + location.get('org') + "/" + location.get('course') + '/' + location.get('category') + '/'
+ location.get('name') + '/gradeas/';
}
else return "";
} }
}); });
return AssignmentGrade; return AssignmentGrade;
......
...@@ -5,12 +5,9 @@ define(["backbone"], function(Backbone) { ...@@ -5,12 +5,9 @@ define(["backbone"], function(Backbone) {
url: '', url: '',
defaults: { defaults: {
"courseId": "", // the location url
"updates" : null, // UpdateCollection "updates" : null, // UpdateCollection
"handouts": null // HandoutCollection "handouts": null // HandoutCollection
}, }
idAttribute : "courseId"
}); });
return CourseInfo; return CourseInfo;
}); });
/**
* A model that simply allows the update URL to be passed
* in as an argument.
*/
define(["backbone"], function(Backbone){
return Backbone.Model.extend({
defaults: {
"explicit_url": ""
},
url: function() {
return this.get("explicit_url");
}
});
});
define(["backbone"], function(Backbone) { define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) {
var ModuleInfo = Backbone.Model.extend({ var ModuleInfo = Backbone.Model.extend({
url: function() {return "/module_info/" + this.id;}, urlRoot: ModuleUtils.urlRoot,
defaults: { defaults: {
"id": null, "id": null,
......
define(["backbone", "gettext", "js/views/feedback_notification"], function(Backbone, gettext, NotificationView) { define(["backbone", "gettext", "js/views/feedback_notification", "js/utils/module"],
function(Backbone, gettext, NotificationView, ModuleUtils) {
var Section = Backbone.Model.extend({ var Section = Backbone.Model.extend({
defaults: { defaults: {
"name": "" "name": ""
...@@ -8,10 +10,9 @@ define(["backbone", "gettext", "js/views/feedback_notification"], function(Backb ...@@ -8,10 +10,9 @@ define(["backbone", "gettext", "js/views/feedback_notification"], function(Backb
return gettext("You must specify a name"); return gettext("You must specify a name");
} }
}, },
url: "/save_item", urlRoot: ModuleUtils.urlRoot,
toJSON: function() { toJSON: function() {
return { return {
id: this.get("id"),
metadata: { metadata: {
display_name: this.get("name") display_name: this.get("name")
} }
......
define(["backbone", "underscore", "gettext", "js/models/location"], function(Backbone, _, gettext, Location) { define(["backbone", "underscore", "gettext"], function(Backbone, _, gettext) {
var CourseDetails = Backbone.Model.extend({ var CourseDetails = Backbone.Model.extend({
defaults: { defaults: {
location : null, // the course's Location model, required org : '',
course_id: '',
run: '',
start_date: null, // maps to 'start' start_date: null, // maps to 'start'
end_date: null, // maps to 'end' end_date: null, // maps to 'end'
enrollment_start: null, enrollment_start: null,
...@@ -17,9 +19,6 @@ var CourseDetails = Backbone.Model.extend({ ...@@ -17,9 +19,6 @@ var CourseDetails = Backbone.Model.extend({
// When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset) // When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset)
parse: function(attributes) { parse: function(attributes) {
if (attributes['course_location']) {
attributes.location = new Location(attributes.course_location, {parse:true});
}
if (attributes['start_date']) { if (attributes['start_date']) {
attributes.start_date = new Date(attributes.start_date); attributes.start_date = new Date(attributes.start_date);
} }
......
...@@ -3,15 +3,11 @@ define(["backbone", "js/models/location", "js/collections/course_grader"], ...@@ -3,15 +3,11 @@ define(["backbone", "js/models/location", "js/collections/course_grader"],
var CourseGradingPolicy = Backbone.Model.extend({ var CourseGradingPolicy = Backbone.Model.extend({
defaults : { defaults : {
course_location : null,
graders : null, // CourseGraderCollection graders : null, // CourseGraderCollection
grade_cutoffs : null, // CourseGradeCutoff model grade_cutoffs : null, // CourseGradeCutoff model
grace_period : null // either null or { hours: n, minutes: m, ...} grace_period : null // either null or { hours: n, minutes: m, ...}
}, },
parse: function(attributes) { parse: function(attributes) {
if (attributes['course_location']) {
attributes.course_location = new Location(attributes.course_location, {parse:true});
}
if (attributes['graders']) { if (attributes['graders']) {
var graderCollection; var graderCollection;
// interesting race condition: if {parse:true} when newing, then parse called before .attributes created // interesting race condition: if {parse:true} when newing, then parse called before .attributes created
...@@ -21,7 +17,6 @@ var CourseGradingPolicy = Backbone.Model.extend({ ...@@ -21,7 +17,6 @@ var CourseGradingPolicy = Backbone.Model.extend({
} }
else { else {
graderCollection = new CourseGraderCollection(attributes.graders, {parse:true}); graderCollection = new CourseGraderCollection(attributes.graders, {parse:true});
graderCollection.course_location = attributes['course_location'] || this.get('course_location');
} }
attributes.graders = graderCollection; attributes.graders = graderCollection;
} }
...@@ -35,10 +30,6 @@ var CourseGradingPolicy = Backbone.Model.extend({ ...@@ -35,10 +30,6 @@ var CourseGradingPolicy = Backbone.Model.extend({
} }
return attributes; return attributes;
}, },
url : function() {
var location = this.get('course_location');
return '/' + location.get('org') + "/" + location.get('course') + '/settings-details/' + location.get('name') + '/section/grading';
},
gracePeriodToDate : function() { gracePeriodToDate : function() {
var newDate = new Date(); var newDate = new Date();
if (this.has('grace_period') && this.get('grace_period')['hours']) if (this.has('grace_period') && this.get('grace_period')['hours'])
......
define(['js/models/explicit_url'],
function (Model) {
describe('Model ', function () {
it('allows url to be passed in constructor', function () {
expect(new Model({'explicit_url': '/fancy/url'}).url()).toBe('/fancy/url');
});
it('returns empty string if url not set', function () {
expect(new Model().url()).toBe('');
});
});
}
);
...@@ -169,11 +169,10 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo ...@@ -169,11 +169,10 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo
}, "Defaults never loaded", 1000); }, "Defaults never loaded", 1000);
runs(function() { runs(function() {
var displayNameValue = collection[0].getValue(),
videoUrlValue = collection[1].getValue();
var displayNameValue = collection[0].getValue(); expect(displayNameValue).toEqual('default');
var videoUrlValue = collection[1].getValue();
expect(displayNameValue).toBe('default');
expect(videoUrlValue).toEqual([ expect(videoUrlValue).toEqual([
'http://youtu.be/OEoXaMPEzfM', 'http://youtu.be/OEoXaMPEzfM',
'default.mp4', 'default.mp4',
...@@ -237,13 +236,13 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo ...@@ -237,13 +236,13 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo
var html5SourcesValue = collection[2].getValue(); var html5SourcesValue = collection[2].getValue();
var youtubeValue = collection[3].getValue(); var youtubeValue = collection[3].getValue();
expect(displayNameValue).toBe('display value'); expect(displayNameValue).toEqual('display value');
expect(subValue).toBe('default'); expect(subValue).toEqual('default');
expect(html5SourcesValue).toEqual([ expect(html5SourcesValue).toEqual([
'video.mp4', 'video.mp4',
'video.webm' 'video.webm'
]); ]);
expect(youtubeValue).toBe('12345678901'); expect(youtubeValue).toEqual('12345678901');
}); });
}); });
...@@ -256,13 +255,13 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo ...@@ -256,13 +255,13 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo
html5SourcesValue = collection[2].getValue(), html5SourcesValue = collection[2].getValue(),
youtubeValue = collection[3].getValue(); youtubeValue = collection[3].getValue();
expect(displayNameValue).toBe('default'); expect(displayNameValue).toEqual('default');
expect(subValue).toBe('default'); expect(subValue).toEqual('default');
expect(html5SourcesValue).toEqual([ expect(html5SourcesValue).toEqual([
'default.mp4', 'default.mp4',
'default.webm' 'default.webm'
]); ]);
expect(youtubeValue).toBe('OEoXaMPEzfM'); expect(youtubeValue).toEqual('OEoXaMPEzfM');
}); });
it('Youtube Id is not adjusted', function () { it('Youtube Id is not adjusted', function () {
...@@ -283,7 +282,7 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo ...@@ -283,7 +282,7 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo
'video.mp4', 'video.mp4',
'video.webm' 'video.webm'
]); ]);
expect(youtubeValue).toBe(''); expect(youtubeValue).toEqual('');
}); });
it('Timed Transcript field is updated', function () { it('Timed Transcript field is updated', function () {
...@@ -294,7 +293,7 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo ...@@ -294,7 +293,7 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo
var collection = metadataCollection.models, var collection = metadataCollection.models,
subValue = collection[1].getValue(); subValue = collection[1].getValue();
expect(subValue).toBe('test_value'); expect(subValue).toEqual('test_value');
}); });
it('Timed Transcript field is updated just once', function () { it('Timed Transcript field is updated just once', function () {
...@@ -309,7 +308,7 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo ...@@ -309,7 +308,7 @@ function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCo
transcripts.syncAdvancedTab(metadataCollection); transcripts.syncAdvancedTab(metadataCollection);
transcripts.syncAdvancedTab(metadataCollection); transcripts.syncAdvancedTab(metadataCollection);
expect(subModel.setValue.calls.length).toBe(1); expect(subModel.setValue.calls.length).toEqual(1);
}); });
}); });
......
...@@ -48,7 +48,7 @@ function ($, _, Utils, FileUploader) { ...@@ -48,7 +48,7 @@ function ($, _, Utils, FileUploader) {
el: $container, el: $container,
messenger: messenger, messenger: messenger,
videoListObject: videoListObject, videoListObject: videoListObject,
component_id: 'component_id' component_locator: 'component_locator'
}); });
}); });
......
...@@ -52,7 +52,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) { ...@@ -52,7 +52,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
view = new MessageManager({ view = new MessageManager({
el: $container, el: $container,
parent: videoList, parent: videoList,
component_id: 'component_id' component_locator: 'component_locator'
}); });
}); });
...@@ -60,7 +60,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) { ...@@ -60,7 +60,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
expect(fileUploader.initialize).toHaveBeenCalledWith({ expect(fileUploader.initialize).toHaveBeenCalledWith({
el: view.$el, el: view.$el,
messenger: view, messenger: view,
component_id: view.component_id, component_locator: view.component_locator,
videoListObject: view.options.parent videoListObject: view.options.parent
}); });
}); });
...@@ -215,7 +215,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) { ...@@ -215,7 +215,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
function() { function() {
expect(Utils.command).toHaveBeenCalledWith( expect(Utils.command).toHaveBeenCalledWith(
action, action,
view.component_id, view.component_locator,
videoList, videoList,
void(0) void(0)
); );
...@@ -245,7 +245,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) { ...@@ -245,7 +245,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
function () { function () {
expect(Utils.command).toHaveBeenCalledWith( expect(Utils.command).toHaveBeenCalledWith(
action, action,
view.component_id, view.component_locator,
videoList, videoList,
{ {
html5_id: extraParamas html5_id: extraParamas
...@@ -268,7 +268,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) { ...@@ -268,7 +268,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
function () { function () {
expect(Utils.command).toHaveBeenCalledWith( expect(Utils.command).toHaveBeenCalledWith(
action, action,
view.component_id, view.component_locator,
videoList, videoList,
void(0) void(0)
); );
......
define(['js/utils/module'],
function (ModuleUtils) {
describe('urlRoot ', function () {
it('defines xblock urlRoot', function () {
expect(ModuleUtils.urlRoot).toBe('/xblock');
});
});
describe('getUpdateUrl ', function () {
it('can take no arguments', function () {
expect(ModuleUtils.getUpdateUrl()).toBe('/xblock');
});
it('appends a locator', function () {
expect(ModuleUtils.getUpdateUrl("locator")).toBe('/xblock/locator');
});
});
}
);
/**
* Utilities for modules/xblocks.
*
* Returns:
*
* urlRoot: the root for creating/updating an xblock.
* getUpdateUrl: a utility method that returns the xblock update URL, appending
* the location if passed in.
*/
define([], function () {
var urlRoot = '/xblock';
var getUpdateUrl = function (locator) {
if (locator === undefined) {
return urlRoot;
}
else {
return urlRoot + "/" + locator;
}
};
return {
urlRoot: urlRoot,
getUpdateUrl: getUpdateUrl
};
});
...@@ -30,6 +30,7 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification" ...@@ -30,6 +30,7 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification"
model: this.model model: this.model
})) }))
); );
$('.handouts-content').html(this.model.get('data'));
this.$preview = this.$el.find('.handouts-content'); this.$preview = this.$el.find('.handouts-content');
this.$form = this.$el.find(".edit-handouts-form"); this.$form = this.$el.find(".edit-handouts-form");
this.$editor = this.$form.find('.handouts-content-editor'); this.$editor = this.$form.find('.handouts-content-editor');
...@@ -50,32 +51,43 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification" ...@@ -50,32 +51,43 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification"
}, },
onSave: function(event) { onSave: function(event) {
this.model.set('data', this.$codeMirror.getValue()); $('#handout_error').removeClass('is-shown');
var saving = new NotificationView.Mini({ $('.save-button').removeClass('is-disabled');
title: gettext('Saving&hellip;') if ($('.CodeMirror-lines').find('.cm-error').length == 0){
}); this.model.set('data', this.$codeMirror.getValue());
saving.show(); var saving = new NotificationView.Mini({
this.model.save({}, { title: gettext('Saving&hellip;')
success: function() { });
saving.hide(); saving.show();
} this.model.save({}, {
}); success: function() {
this.render(); saving.hide();
this.$form.hide(); }
this.closeEditor(); });
this.render();
analytics.track('Saved Course Handouts', { this.$form.hide();
'course': course_location_analytics this.closeEditor();
});
analytics.track('Saved Course Handouts', {
'course': course_location_analytics
});
}else{
$('#handout_error').addClass('is-shown');
$('.save-button').addClass('is-disabled');
event.preventDefault();
}
}, },
onCancel: function(event) { onCancel: function(event) {
$('#handout_error').removeClass('is-shown');
$('.save-button').removeClass('is-disabled');
this.$form.hide(); this.$form.hide();
this.closeEditor(); this.closeEditor();
}, },
closeEditor: function() { closeEditor: function() {
$('#handout_error').removeClass('is-shown');
$('.save-button').removeClass('is-disabled');
this.$form.hide(); this.$form.hide();
ModalUtils.hideModalCover(); ModalUtils.hideModalCover();
this.$form.find('.CodeMirror').remove(); this.$form.find('.CodeMirror').remove();
......
...@@ -6,7 +6,10 @@ define(["codemirror", "utility"], ...@@ -6,7 +6,10 @@ define(["codemirror", "utility"],
var $codeMirror = CodeMirror.fromTextArea(textArea, { var $codeMirror = CodeMirror.fromTextArea(textArea, {
mode: "text/html", mode: "text/html",
lineNumbers: true, lineNumbers: true,
lineWrapping: true lineWrapping: true,
onChange: function () {
$('.save-button').removeClass('is-disabled');
}
}); });
$codeMirror.setValue(content); $codeMirror.setValue(content);
$codeMirror.clearHistory(); $codeMirror.clearHistory();
......
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