Commit ddc45ea5 by John Jarvis

Merge pull request #1821 from edx/rc/2013-12-03

rc/2013 12 03
parents 85495bb5 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
...@@ -48,14 +49,18 @@ reports/ ...@@ -48,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
......
...@@ -97,3 +97,5 @@ Iain Dunning <idunning@mit.edu> ...@@ -97,3 +97,5 @@ Iain Dunning <idunning@mit.edu>
Olivier Marquez <oliviermarquez@gmail.com> Olivier Marquez <oliviermarquez@gmail.com>
Florian Dufour <neurolit@gmail.com> Florian Dufour <neurolit@gmail.com>
Manuel Freire <manuel.freire@fdi.ucm.es> Manuel Freire <manuel.freire@fdi.ucm.es>
Daniel Cebrián Robles <danielcebrianr@gmail.com>
Carson Gee <cgee@mit.edu>
...@@ -5,6 +5,11 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,11 @@ 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 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 task, with reports uploaded to S3. Feature is visible on the beta
instructor dashboard. LMS-58 instructor dashboard. LMS-58
...@@ -13,9 +18,38 @@ Blades: Added grading support for LTI module. LTI providers can now grade ...@@ -13,9 +18,38 @@ Blades: Added grading support for LTI module. LTI providers can now grade
student's work and send edX scores. OAuth1 based authentication student's work and send edX scores. OAuth1 based authentication
implemented. BLD-384. implemented. BLD-384.
LMS: Beta-tester status is now set on a per-course-run basis, rather than being valid LMS: Beta-tester status is now set on a per-course-run basis, rather than being
across all runs with the same course name. Old group membership will still work valid across all runs with the same course name. Old group membership will
across runs, but new beta-testers will only be added to a single course run. 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. LMS: Add a user-visible alert modal when a forums AJAX request fails.
...@@ -36,7 +70,8 @@ text like with bold or italics. (BLD-449) ...@@ -36,7 +70,8 @@ text like with bold or italics. (BLD-449)
LMS: Beta instructor dashboard will only count actively enrolled students for LMS: Beta instructor dashboard will only count actively enrolled students for
course enrollment numbers. course enrollment numbers.
Blades: Fix speed menu that is not rendered correctly when YouTube is unavailable. (BLD-457). 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 LMS: Users with is_staff=True no longer have the STAFF label appear on
their forum posts. their forum posts.
...@@ -54,6 +89,9 @@ key in course settings. (BLD-426) ...@@ -54,6 +89,9 @@ key in course settings. (BLD-426)
Blades: Fix bug when the speed can only be changed when the video is playing. 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.
...@@ -70,8 +108,8 @@ client error are correctly passed through to the client. ...@@ -70,8 +108,8 @@ 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
LMS: The wiki markup cheatsheet dialog is now accessible to people with LMS: The wiki markup cheatsheet dialog is now accessible to screen readers.
disabilites. (LMS-1303) (LMS-1303)
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 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,7 +14,8 @@ from django.conf import settings ...@@ -11,7 +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 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
...@@ -26,7 +30,11 @@ COURSE_CREATOR_GROUP_NAME = "course_creator_group" ...@@ -26,7 +30,11 @@ 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>
...@@ -38,22 +46,46 @@ def get_course_groupname_for_role(location, role): ...@@ -38,22 +46,46 @@ def get_course_groupname_for_role(location, role):
except InvalidLocationError: # will occur on old locations where location is not of category course except InvalidLocationError: # will occur on old locations where location is not of category course
pass 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, get_course=True) 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):
...@@ -65,11 +97,11 @@ def create_all_course_groups(creator, location): ...@@ -65,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()
...@@ -82,15 +114,13 @@ def _delete_course_group(location): ...@@ -82,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):
...@@ -98,25 +128,25 @@ def _copy_course_group(source, dest): ...@@ -98,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)
...@@ -132,9 +162,7 @@ def add_user_to_creator_group(caller, user): ...@@ -132,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)
...@@ -152,6 +180,9 @@ def _add_user_to_group(user, group): ...@@ -152,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:
...@@ -163,13 +194,21 @@ def get_user_by_email(email): ...@@ -163,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):
...@@ -195,11 +234,16 @@ def _remove_user_from_group(user, group_name): ...@@ -195,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
......
...@@ -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
......
...@@ -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'
......
...@@ -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
...@@ -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()])
......
...@@ -53,6 +53,8 @@ Feature: CMS.Video Component ...@@ -53,6 +53,8 @@ Feature: CMS.Video Component
Then Captions become "invisible" 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,6 +65,8 @@ Feature: CMS.Video Component ...@@ -63,6 +65,8 @@ 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
...@@ -71,6 +75,8 @@ Feature: CMS.Video Component ...@@ -71,6 +75,8 @@ Feature: CMS.Video Component
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
......
...@@ -181,7 +181,7 @@ def click_on_the_caption(_step, index): ...@@ -181,7 +181,7 @@ def click_on_the_caption(_step, index):
@step('I see caption line with data-index "([^"]*)" 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$') @step('I see a range on slider$')
......
...@@ -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
...@@ -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')
......
...@@ -263,7 +263,7 @@ class ExportTestCase(CourseTestCase): ...@@ -263,7 +263,7 @@ class ExportTestCase(CourseTestCase):
parent_location=vertical.location, parent_location=vertical.location,
category='aawefawef' category='aawefawef'
) )
self._verify_export_failure('/edit/i4x://MITx/999/vertical/foo') self._verify_export_failure(u'/unit/MITx.999.Robot_Super_Course/branch/draft/block/foo')
def _verify_export_failure(self, expectedText): def _verify_export_failure(self, expectedText):
""" Export failure helper method. """ """ Export failure helper method. """
......
...@@ -9,6 +9,7 @@ from xmodule.capa_module import CapaDescriptor ...@@ -9,6 +9,7 @@ from xmodule.capa_module import CapaDescriptor
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.locator import BlockUsageLocator
from xmodule.modulestore.exceptions import ItemNotFoundError
class ItemTest(CourseTestCase): class ItemTest(CourseTestCase):
...@@ -30,7 +31,7 @@ class ItemTest(CourseTestCase): ...@@ -30,7 +31,7 @@ class ItemTest(CourseTestCase):
""" """
Get the item referenced by the locator from the modulestore Get the item referenced by the locator from the modulestore
""" """
store = modulestore('draft') if draft else modulestore() store = modulestore('draft') if draft else modulestore('direct')
return store.get_item(self.get_old_id(locator)) return store.get_item(self.get_old_id(locator))
def response_locator(self, response): def response_locator(self, response):
...@@ -251,3 +252,105 @@ class TestEditItem(ItemTest): ...@@ -251,3 +252,105 @@ class TestEditItem(ItemTest):
self.assertEqual(self.get_old_id(self.problem_locator).url(), children[0]) self.assertEqual(self.get_old_id(self.problem_locator).url(), children[0])
self.assertEqual(self.get_old_id(unit1_locator).url(), children[2]) self.assertEqual(self.get_old_id(unit1_locator).url(), children[2])
self.assertEqual(self.get_old_id(unit2_locator).url(), children[1]) self.assertEqual(self.get_old_id(unit2_locator).url(), children[1])
def test_make_public(self):
""" Test making a private problem public (publishing it). """
# When the problem is first created, it is only in draft (because of its category).
with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_locator, False)
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
def test_make_private(self):
""" Test making a public problem private (un-publishing it). """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
# Now make it private
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_private'}
)
with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_locator, False)
def test_make_draft(self):
""" Test creating a draft version of a public problem. """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
# Now make it draft, which means both versions will exist.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'create_draft'}
)
# Update the draft version and check that published is different.
self.client.ajax_post(
self.problem_update_url,
data={'metadata': {'due': '2077-10-10T04:00Z'}}
)
published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertIsNone(published.due)
draft = self.get_item_from_modulestore(self.problem_locator, True)
self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_make_public_with_update(self):
""" Update a problem and make it public at the same time. """
self.client.ajax_post(
self.problem_update_url,
data={
'metadata': {'due': '2077-10-10T04:00Z'},
'publish': 'make_public'
}
)
published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertEqual(published.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_make_private_with_update(self):
""" Make a problem private and update it at the same time. """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.client.ajax_post(
self.problem_update_url,
data={
'metadata': {'due': '2077-10-10T04:00Z'},
'publish': 'make_private'
}
)
with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_locator, False)
draft = self.get_item_from_modulestore(self.problem_locator, True)
self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_create_draft_with_update(self):
""" Create a draft and update it at the same time. """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
# Now make it draft, which means both versions will exist.
self.client.ajax_post(
self.problem_update_url,
data={
'metadata': {'due': '2077-10-10T04:00Z'},
'publish': 'create_draft'
}
)
published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertIsNone(published.due)
draft = self.get_item_from_modulestore(self.problem_locator, True)
self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
"""
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
...@@ -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,
......
...@@ -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):
...@@ -41,6 +42,7 @@ class AjaxEnabledTestClient(Client): ...@@ -41,6 +42,7 @@ class AjaxEnabledTestClient(Client):
if not isinstance(data, basestring): if not isinstance(data, basestring):
data = json.dumps(data or {}) data = json.dumps(data or {})
kwargs.setdefault("HTTP_X_REQUESTED_WITH", "XMLHttpRequest") 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) return self.post(path=path, data=data, content_type=content_type, **kwargs)
def get_html(self, path, data=None, follow=False, **extra): def get_html(self, path, data=None, follow=False, **extra):
...@@ -88,6 +90,9 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -88,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):
""" """
...@@ -106,3 +111,16 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -106,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
...@@ -14,7 +14,6 @@ from django.conf import settings ...@@ -14,7 +14,6 @@ from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse
from django.core.servers.basehttp import FileWrapper from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile from django.core.files.temp import NamedTemporaryFile
from django.core.exceptions import SuspiciousOperation, PermissionDenied from django.core.exceptions import SuspiciousOperation, PermissionDenied
...@@ -140,7 +139,7 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -140,7 +139,7 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid=
"size": size, "size": size,
"deleteUrl": "", "deleteUrl": "",
"deleteType": "", "deleteType": "",
"url": location.url_reverse('import/', ''), "url": location.url_reverse('import'),
"thumbnailUrl": "" "thumbnailUrl": ""
}] }]
}) })
...@@ -252,8 +251,8 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -252,8 +251,8 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid=
course_module = modulestore().get_item(old_location) course_module = modulestore().get_item(old_location)
return render_to_response('import.html', { return render_to_response('import.html', {
'context_course': course_module, 'context_course': course_module,
'successful_import_redirect_url': location.url_reverse("course/", ""), 'successful_import_redirect_url': location.url_reverse("course"),
'import_status_url': location.url_reverse("import_status/", "fillerName"), 'import_status_url': location.url_reverse("import_status", "fillerName"),
}) })
else: else:
return HttpResponseNotFound() return HttpResponseNotFound()
...@@ -313,7 +312,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -313,7 +312,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid=
# an _accept URL parameter will be preferred over HTTP_ACCEPT in the header. # an _accept URL parameter will be preferred over HTTP_ACCEPT in the header.
requested_format = request.REQUEST.get('_accept', request.META.get('HTTP_ACCEPT', 'text/html')) requested_format = request.REQUEST.get('_accept', request.META.get('HTTP_ACCEPT', 'text/html'))
export_url = location.url_reverse('export/', '') + '?_accept=application/x-tgz' export_url = location.url_reverse('export') + '?_accept=application/x-tgz'
if 'application/x-tgz' in requested_format: if 'application/x-tgz' in requested_format:
name = old_location.name name = old_location.name
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
...@@ -339,16 +338,16 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -339,16 +338,16 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid=
# if we have a nested exception, then we'll show the more generic error message # if we have a nested exception, then we'll show the more generic error message
pass pass
unit_locator = loc_mapper().translate_location(old_location.course_id, parent.location, False, True)
return render_to_response('export.html', { return render_to_response('export.html', {
'context_course': course_module, 'context_course': course_module,
'in_err': True, 'in_err': True,
'raw_err_msg': str(e), 'raw_err_msg': str(e),
'failed_module': failed_item, 'failed_module': failed_item,
'unit': unit, 'unit': unit,
'edit_unit_url': reverse('edit_unit', kwargs={ 'edit_unit_url': unit_locator.url_reverse("unit") if parent else "",
'location': parent.location 'course_home_url': location.url_reverse("course"),
}) if parent else '',
'course_home_url': location.url_reverse("course/", ""),
'export_url': export_url 'export_url': export_url
}) })
...@@ -359,7 +358,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -359,7 +358,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid=
'in_err': True, 'in_err': True,
'unit': None, 'unit': None,
'raw_err_msg': str(e), 'raw_err_msg': str(e),
'course_home_url': location.url_reverse("course/", ""), 'course_home_url': location.url_reverse("course"),
'export_url': export_url 'export_url': export_url
}) })
......
...@@ -3,7 +3,9 @@ ...@@ -3,7 +3,9 @@
import logging import logging
from uuid import uuid4 from uuid import uuid4
from functools import partial
from static_replace import replace_static_urls from static_replace import replace_static_urls
from xmodule_modifiers import wrap_xblock
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
...@@ -27,6 +29,9 @@ from xmodule.modulestore.locator import BlockUsageLocator ...@@ -27,6 +29,9 @@ from xmodule.modulestore.locator import BlockUsageLocator
from student.models import CourseEnrollment from student.models import CourseEnrollment
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from xblock.fields import Scope from xblock.fields import Scope
from preview import handler_prefix, get_preview_html
from mitxmako.shortcuts import render_to_response, render_to_string
from models.settings.course_grading import CourseGradingModel
__all__ = ['orphan_handler', 'xblock_handler'] __all__ = ['orphan_handler', 'xblock_handler']
...@@ -51,17 +56,21 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -51,17 +56,21 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=
all children and "all_versions" to delete from all (mongo) versions. all children and "all_versions" to delete from all (mongo) versions.
GET GET
json: returns representation of the xblock (locator id, data, and metadata). json: returns representation of the xblock (locator id, data, and metadata).
if ?fields=graderType, it returns the graderType for the unit instead of the above.
html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view)
PUT or POST PUT or POST
json: if xblock location is specified, update the xblock instance. The json payload can contain json: if xblock locator is specified, update the xblock instance. The json payload can contain
these fields, all optional: these fields, all optional:
:data: the new value for the data. :data: the new value for the data.
:children: the locator ids of children for this xblock. :children: the locator ids of children for this xblock.
:metadata: new values for the metadata fields. Any whose values are None will be deleted not set :metadata: new values for the metadata fields. Any whose values are None will be deleted not set
to None! Absent ones will be left alone. to None! Absent ones will be left alone.
:nullout: which metadata fields to set to None :nullout: which metadata fields to set to None
:graderType: change how this unit is graded
:publish: can be one of three values, 'make_public, 'make_private', or 'create_draft'
The JSON representation on the updated xblock (minus children) is returned. The JSON representation on the updated xblock (minus children) is returned.
if xblock location is not specified, create a new xblock instance. The json playload can contain if xblock locator is not specified, create a new xblock instance. The json playload can contain
these fields: these fields:
:parent_locator: parent for new xblock, required :parent_locator: parent for new xblock, required
:category: type of xblock, required :category: type of xblock, required
...@@ -70,14 +79,38 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -70,14 +79,38 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=
The locator (and old-style id) for the created xblock (minus children) is returned. The locator (and old-style id) for the created xblock (minus children) is returned.
""" """
if course_id is not None: if course_id is not None:
location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
if not has_access(request.user, location): if not has_access(request.user, locator):
raise PermissionDenied() raise PermissionDenied()
old_location = loc_mapper().translate_locator_to_location(location) old_location = loc_mapper().translate_locator_to_location(locator)
if request.method == 'GET': if request.method == 'GET':
rsp = _get_module_info(location) if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
return JsonResponse(rsp) fields = request.REQUEST.get('fields', '').split(',')
if 'graderType' in fields:
# right now can't combine output of this w/ output of _get_module_info, but worthy goal
return JsonResponse(CourseGradingModel.get_section_grader_type(locator))
# TODO: pass fields to _get_module_info and only return those
rsp = _get_module_info(locator)
return JsonResponse(rsp)
else:
component = modulestore().get_item(old_location)
# Wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
component.runtime.wrappers.append(partial(wrap_xblock, handler_prefix))
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),
'editor': content
})
elif request.method == 'DELETE': elif request.method == 'DELETE':
delete_children = str_to_bool(request.REQUEST.get('recurse', 'False')) delete_children = str_to_bool(request.REQUEST.get('recurse', 'False'))
delete_all_versions = str_to_bool(request.REQUEST.get('all_versions', 'False')) delete_all_versions = str_to_bool(request.REQUEST.get('all_versions', 'False'))
...@@ -85,12 +118,15 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -85,12 +118,15 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=
return _delete_item_at_location(old_location, delete_children, delete_all_versions) return _delete_item_at_location(old_location, delete_children, delete_all_versions)
else: # Since we have a course_id, we are updating an existing xblock. else: # Since we have a course_id, we are updating an existing xblock.
return _save_item( return _save_item(
location, request,
locator,
old_location, old_location,
data=request.json.get('data'), data=request.json.get('data'),
children=request.json.get('children'), children=request.json.get('children'),
metadata=request.json.get('metadata'), metadata=request.json.get('metadata'),
nullout=request.json.get('nullout') nullout=request.json.get('nullout'),
grader_type=request.json.get('graderType'),
publish=request.json.get('publish'),
) )
elif request.method in ('PUT', 'POST'): elif request.method in ('PUT', 'POST'):
return _create_item(request) return _create_item(request)
...@@ -101,11 +137,14 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -101,11 +137,14 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=
) )
def _save_item(usage_loc, item_location, data=None, children=None, metadata=None, nullout=None): def _save_item(request, usage_loc, item_location, data=None, children=None, metadata=None, nullout=None,
grader_type=None, publish=None):
""" """
Saves certain properties (data, children, metadata, nullout) for a given xblock item. Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata.
nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert
to default).
The item_location is still the old-style location. The item_location is still the old-style location whereas usage_loc is a BlockUsageLocator
""" """
store = get_modulestore(item_location) store = get_modulestore(item_location)
...@@ -123,6 +162,14 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None ...@@ -123,6 +162,14 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None
log.error("Can't find item by location.") log.error("Can't find item by location.")
return JsonResponse({"error": "Can't find item by location: " + str(item_location)}, 404) return JsonResponse({"error": "Can't find item by location: " + str(item_location)}, 404)
if publish:
if publish == 'make_private':
_xmodule_recurse(existing_item, lambda i: modulestore().unpublish(i.location))
elif publish == 'create_draft':
# This clones the existing item location to a draft location (the draft is
# implicit, because modulestore is a Draft modulestore)
modulestore().convert_to_draft(item_location)
if data: if data:
store.update_item(item_location, data) store.update_item(item_location, data)
else: else:
...@@ -170,12 +217,25 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None ...@@ -170,12 +217,25 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None
if existing_item.category == 'video': if existing_item.category == 'video':
manage_video_subtitles_save(existing_item, existing_item) manage_video_subtitles_save(existing_item, existing_item)
# Note that children aren't being returned until we have a use case. result = {
return JsonResponse({
'id': unicode(usage_loc), 'id': unicode(usage_loc),
'data': data, 'data': data,
'metadata': own_metadata(existing_item) 'metadata': own_metadata(existing_item)
}) }
if grader_type is not None:
result.update(CourseGradingModel.update_section_grader_type(existing_item, grader_type))
# Make public after updating the xblock, in case the caller asked
# for both an update and a publish.
if publish and publish == 'make_public':
_xmodule_recurse(
existing_item,
lambda i: modulestore().publish(i.location, request.user.id)
)
# Note that children aren't being returned until we have a use case.
return JsonResponse(result)
@login_required @login_required
...@@ -192,10 +252,7 @@ def _create_item(request): ...@@ -192,10 +252,7 @@ def _create_item(request):
raise PermissionDenied() raise PermissionDenied()
parent = get_modulestore(category).get_item(parent_location) parent = get_modulestore(category).get_item(parent_location)
# Necessary to set revision=None or else metadata inheritance does not work dest_location = parent_location.replace(category=category, name=uuid4().hex)
# (the ID with @draft will be used as the key in the inherited metadata map,
# and that is not expected by the code that later references it).
dest_location = parent_location.replace(category=category, name=uuid4().hex, revision=None)
# get the metadata, display_name, and definition from the request # get the metadata, display_name, and definition from the request
metadata = {} metadata = {}
...@@ -224,7 +281,7 @@ def _create_item(request): ...@@ -224,7 +281,7 @@ def _create_item(request):
course_location = loc_mapper().translate_locator_to_location(parent_locator, get_course=True) course_location = loc_mapper().translate_locator_to_location(parent_locator, get_course=True)
locator = loc_mapper().translate_location(course_location.course_id, dest_location, False, True) locator = loc_mapper().translate_location(course_location.course_id, dest_location, False, True)
return JsonResponse({'id': dest_location.url(), "locator": unicode(locator)}) return JsonResponse({"locator": unicode(locator)})
def _delete_item_at_location(item_location, delete_children=False, delete_all_versions=False): def _delete_item_at_location(item_location, delete_children=False, delete_all_versions=False):
......
...@@ -3,7 +3,7 @@ from functools import partial ...@@ -3,7 +3,7 @@ from functools import partial
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden 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
...@@ -24,10 +24,9 @@ from util.sandboxing import can_execute_unsafe_code ...@@ -24,10 +24,9 @@ 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_handler', 'preview_component'] __all__ = ['preview_handler']
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -53,13 +52,13 @@ def preview_handler(request, usage_id, handler, suffix=''): ...@@ -53,13 +52,13 @@ def preview_handler(request, usage_id, handler, suffix=''):
usage_id: The usage-id of the block to dispatch to, passed through `quote_slashes` usage_id: The usage-id of the block to dispatch to, passed through `quote_slashes`
handler: The handler to execute handler: The handler to execute
suffix: The remaineder of the url to be passed to the handler suffix: The remainder of the url to be passed to the handler
""" """
location = unquote_slashes(usage_id) location = unquote_slashes(usage_id)
descriptor = modulestore().get_item(location) descriptor = modulestore().get_item(location)
instance = load_preview_module(request, 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) req = django_to_webob_request(request)
try: try:
...@@ -85,32 +84,6 @@ def preview_handler(request, usage_id, handler, suffix=''): ...@@ -85,32 +84,6 @@ def preview_handler(request, usage_id, handler, suffix=''):
return webob_to_django_response(resp) return webob_to_django_response(resp)
@login_required
def preview_component(request, location):
"Return the HTML preview of a component"
# TODO (vshnayder): change name from id to location in coffee+html as well.
if not has_access(request.user, location):
return HttpResponseForbidden()
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(partial(wrap_xblock, handler_prefix))
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),
'editor': content
})
class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
""" """
An XModule ModuleSystem for use in Studio previews An XModule ModuleSystem for use in Studio previews
...@@ -119,7 +92,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method ...@@ -119,7 +92,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
return handler_prefix(block, handler_name, suffix) + '?' + query return handler_prefix(block, handler_name, suffix) + '?' + query
def preview_module_system(request, 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.
...@@ -135,7 +108,7 @@ def preview_module_system(request, descriptor): ...@@ -135,7 +108,7 @@ def preview_module_system(request, descriptor):
# 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), 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),
...@@ -162,7 +135,7 @@ def preview_module_system(request, descriptor): ...@@ -162,7 +135,7 @@ def preview_module_system(request, descriptor):
) )
def load_preview_module(request, 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.
...@@ -171,7 +144,7 @@ def load_preview_module(request, descriptor): ...@@ -171,7 +144,7 @@ def load_preview_module(request, descriptor):
""" """
student_data = DbModel(SessionKeyValueStore(request)) student_data = DbModel(SessionKeyValueStore(request))
descriptor.bind_for_student( descriptor.bind_for_student(
preview_module_system(request, 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
...@@ -182,7 +155,7 @@ def get_preview_html(request, descriptor): ...@@ -182,7 +155,7 @@ 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, 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
......
...@@ -10,7 +10,7 @@ from mitxmako.shortcuts import render_to_response ...@@ -10,7 +10,7 @@ from mitxmako.shortcuts import render_to_response
from external_auth.views import ssl_login_shortcut from external_auth.views import ssl_login_shortcut
__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks'] __all__ = ['signup', 'login_page', 'howitworks']
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -22,13 +22,6 @@ def signup(request): ...@@ -22,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):
......
...@@ -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.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
...@@ -10,9 +9,11 @@ from django_future.csrf import ensure_csrf_cookie ...@@ -10,9 +9,11 @@ from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore, loc_mapper from xmodule.modulestore.django import modulestore, loc_mapper
from util.json_request import JsonResponse from util.json_request import JsonResponse, expect_json
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,
get_course_role_users
)
from course_creators.views import user_requested_access from course_creators.views import user_requested_access
from .access import has_access from .access import has_access
...@@ -35,6 +36,7 @@ def request_course_creator(request): ...@@ -35,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"))
...@@ -62,38 +64,39 @@ def course_team_handler(request, tag=None, course_id=None, branch=None, version_ ...@@ -62,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:
...@@ -123,7 +126,7 @@ def _course_team_user(request, location, email): ...@@ -123,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
...@@ -139,7 +142,7 @@ def _course_team_user(request, location, email): ...@@ -139,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
...@@ -162,22 +165,13 @@ def _course_team_user(request, location, email): ...@@ -162,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")
} }
...@@ -203,4 +197,3 @@ def _course_team_user(request, location, email): ...@@ -203,4 +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()
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)
...@@ -166,9 +166,14 @@ SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY') ...@@ -166,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']
......
...@@ -23,7 +23,8 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' ...@@ -23,7 +23,8 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
################################# LMS INTEGRATION ############################# ################################# LMS INTEGRATION #############################
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost:8000" LMS_BASE = "localhost:8000"
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview." + LMS_BASE
################################# CELERY ###################################### ################################# CELERY ######################################
......
...@@ -197,7 +197,8 @@ define([ ...@@ -197,7 +197,8 @@ define([
"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/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
......
...@@ -196,3 +196,22 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model ...@@ -196,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"
@stubModule.get = (param)->
if param == 'old_id'
return 'stub-old-id'
setFixtures """ setFixtures """
<li class="component" id="stub-id"> <li class="component" id="stub-id">
...@@ -62,7 +59,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> ...@@ -62,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.get('old_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()
......
...@@ -36,7 +36,7 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base ...@@ -36,7 +36,7 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base
appendSetFixtures """ appendSetFixtures """
<section class="courseware-section branch" data-locator="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" data-locator="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>
......
...@@ -69,15 +69,13 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", ...@@ -69,15 +69,13 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
payload payload
(data) => (data) =>
@model.set(id: data.locator) @model.set(id: data.locator)
@model.set(old_id: data.id)
@$el.data('id', data.id)
@$el.data('locator', data.locator) @$el.data('locator', data.locator)
@render() @render()
) )
render: -> render: ->
if @model.get('old_id') if @model.id
@$el.load("/preview_component/#{@model.get('old_id')}", => @$el.load(@model.url(), =>
@loadDisplay() @loadDisplay()
@delegateEvents() @delegateEvents()
) )
......
...@@ -6,8 +6,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views ...@@ -6,8 +6,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
initialize: => initialize: =>
@$('.component').each((idx, element) => @$('.component').each((idx, element) =>
model = new ModuleModel({ model = new ModuleModel({
id: $(element).data('locator'), id: $(element).data('locator')
old_id:$(element).data('id')
}) })
new ModuleEditView( new ModuleEditView(
...@@ -38,14 +37,17 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views ...@@ -38,14 +37,17 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
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()
......
...@@ -63,7 +63,6 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -63,7 +63,6 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@$('.component').each (idx, element) => @$('.component').each (idx, element) =>
model = new ModuleModel model = new ModuleModel
id: $(element).data('locator') id: $(element).data('locator')
old_id: $(element).data('id')
new ModuleEditView new ModuleEditView
el: element, el: element,
onDelete: @deleteComponent, onDelete: @deleteComponent,
...@@ -167,7 +166,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -167,7 +166,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@wait(true) @wait(true)
$.ajax({ $.ajax({
type: 'DELETE', type: 'DELETE',
url: @model.urlRoot + "/" + @$el.data('locator') + "?" + $.param({recurse: true}) url: @model.url() + "?" + $.param({recurse: true})
}).success(=> }).success(=>
analytics.track "Deleted Draft", analytics.track "Deleted Draft",
...@@ -180,8 +179,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -180,8 +179,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
createDraft: (event) -> createDraft: (event) ->
@wait(true) @wait(true)
$.postJSON('/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
...@@ -194,8 +193,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -194,8 +193,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@wait(true) @wait(true)
@saveDraft() @saveDraft()
$.postJSON('/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
...@@ -206,16 +205,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -206,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)
$.postJSON(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
......
...@@ -237,7 +237,7 @@ function createNewUnit(e) { ...@@ -237,7 +237,7 @@ function createNewUnit(e) {
function(data) { function(data) {
// redirect to the edit page // redirect to the edit page
window.location = "/edit/" + data['id']; window.location = "/unit/" + data['locator'];
}); });
} }
......
...@@ -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);
} }
......
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", "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('');
});
});
}
);
...@@ -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)
); );
......
...@@ -11,7 +11,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s ...@@ -11,7 +11,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s
'transcripts/metadata-videolist-entry.underscore' 'transcripts/metadata-videolist-entry.underscore'
), ),
abstractEditor = AbstractEditor.prototype, abstractEditor = AbstractEditor.prototype,
component_id = 'component_id', component_locator = 'component_locator',
videoList = [ videoList = [
{ {
mode: "youtube", mode: "youtube",
...@@ -62,7 +62,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s ...@@ -62,7 +62,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s
var tpl = sandbox({ var tpl = sandbox({
'class': 'component', 'class': 'component',
'data-id': component_id 'data-locator': component_locator
}), }),
model = new MetadataModel(modelStub), model = new MetadataModel(modelStub),
videoList, $el; videoList, $el;
...@@ -157,7 +157,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s ...@@ -157,7 +157,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s
waitsForResponse(function () { waitsForResponse(function () {
expect(abstractEditor.initialize).toHaveBeenCalled(); expect(abstractEditor.initialize).toHaveBeenCalled();
expect(messenger.initialize).toHaveBeenCalled(); expect(messenger.initialize).toHaveBeenCalled();
expect(view.component_id).toBe(component_id); expect(view.component_locator).toBe(component_locator);
expect(view.$el).toHandle('input'); expect(view.$el).toHandle('input');
}); });
}); });
...@@ -167,7 +167,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s ...@@ -167,7 +167,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s
expect(abstractEditor.render).toHaveBeenCalled(); expect(abstractEditor.render).toHaveBeenCalled();
expect(Utils.command).toHaveBeenCalledWith( expect(Utils.command).toHaveBeenCalledWith(
'check', 'check',
component_id, component_locator,
videoList videoList
); );
......
...@@ -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();
......
...@@ -21,7 +21,7 @@ define(["backbone", "underscore", "gettext", "js/models/assignment_grade", "js/v ...@@ -21,7 +21,7 @@ define(["backbone", "underscore", "gettext", "js/models/assignment_grade", "js/v
'<li><a class="gradable-status-notgraded" href="#">Not Graded</a></li>' + '<li><a class="gradable-status-notgraded" href="#">Not Graded</a></li>' +
'</ul>'); '</ul>');
this.assignmentGrade = new AssignmentGrade({ this.assignmentGrade = new AssignmentGrade({
assignmentUrl : this.$el.closest('.id-holder').data('id'), locator : this.$el.closest('.id-holder').data('locator'),
graderType : this.$el.data('initial-status')}); graderType : this.$el.data('initial-status')});
// TODO throw exception if graders is null // TODO throw exception if graders is null
this.graders = this.options['graders']; this.graders = this.options['graders'];
......
...@@ -21,9 +21,9 @@ var DetailsView = ValidatingView.extend({ ...@@ -21,9 +21,9 @@ var DetailsView = ValidatingView.extend({
initialize : function() { initialize : function() {
this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="icon-file"></i><%= filename %></a>'); this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="icon-file"></i><%= filename %></a>');
// fill in fields // fill in fields
this.$el.find("#course-name").val(this.model.get('location').get('name')); this.$el.find("#course-organization").val(this.model.get('org'));
this.$el.find("#course-organization").val(this.model.get('location').get('org')); this.$el.find("#course-number").val(this.model.get('course_id'));
this.$el.find("#course-number").val(this.model.get('location').get('course')); this.$el.find("#course-name").val(this.model.get('run'));
this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
// Avoid showing broken image on mistyped/nonexistent image // Avoid showing broken image on mistyped/nonexistent image
......
...@@ -72,7 +72,7 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) { ...@@ -72,7 +72,7 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
syncBasicTab: function (metadataCollection, metadataView) { syncBasicTab: function (metadataCollection, metadataView) {
var result = [], var result = [],
getField = Utils.getField, getField = Utils.getField,
component_id = this.$el.closest('.component').data('id'), component_locator = this.$el.closest('.component').data('locator'),
subs = getField(metadataCollection, 'sub'), subs = getField(metadataCollection, 'sub'),
values = {}, values = {},
videoUrl, metadata, modifiedValues; videoUrl, metadata, modifiedValues;
...@@ -99,7 +99,7 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) { ...@@ -99,7 +99,7 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
if (isSubsModified) { if (isSubsModified) {
metadata = $.extend(true, {}, modifiedValues); metadata = $.extend(true, {}, modifiedValues);
// Save module state // Save module state
Utils.command('save', component_id, null, { Utils.command('save', component_locator, null, {
metadata: metadata, metadata: metadata,
current_subs: _.pluck( current_subs: _.pluck(
Utils.getVideoList(videoUrl.getDisplayValue()), Utils.getVideoList(videoUrl.getDisplayValue()),
...@@ -110,18 +110,16 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) { ...@@ -110,18 +110,16 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
// Get values from `Advanced` tab fields (`html5_sources`, // Get values from `Advanced` tab fields (`html5_sources`,
// `youtube_id_1_0`) that should be synchronized. // `youtube_id_1_0`) that should be synchronized.
html5Sources = getField(metadataCollection, 'html5_sources') var html5Sources = getField(metadataCollection, 'html5_sources').getDisplayValue();
                                    .getDisplayValue();
            values.youtube = getField(metadataCollection, 'youtube_id_1_0') values.youtube = getField(metadataCollection, 'youtube_id_1_0').getDisplayValue();
                                    .getDisplayValue();
            values.html5Sources = _.filter(html5Sources, function (value) { values.html5Sources = _.filter(html5Sources, function (value) {
                var link = Utils.parseLink(value), var link = Utils.parseLink(value),
mode = link && link.mode; mode = link && link.mode;
                return mode === 'html5' && mode; return mode === 'html5' && mode;
            }); });
// The length of youtube video_id should be 11 characters. // The length of youtube video_id should be 11 characters.
......
...@@ -39,7 +39,7 @@ function($, Backbone, _, Utils) { ...@@ -39,7 +39,7 @@ function($, Backbone, _, Utils) {
tplContainer.html(this.template({ tplContainer.html(this.template({
ext: this.validFileExtensions, ext: this.validFileExtensions,
component_id: this.options.component_id, component_locator: this.options.component_locator,
video_list: videoList video_list: videoList
})); }));
......
...@@ -31,12 +31,12 @@ function($, Backbone, _, Utils, FileUploader, gettext) { ...@@ -31,12 +31,12 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
initialize: function () { initialize: function () {
_.bindAll(this); _.bindAll(this);
this.component_id = this.$el.closest('.component').data('id'); this.component_locator = this.$el.closest('.component').data('locator');
this.fileUploader = new FileUploader({ this.fileUploader = new FileUploader({
el: this.$el, el: this.$el,
messenger: this, messenger: this,
component_id: this.component_id, component_locator: this.component_locator,
videoListObject: this.options.parent videoListObject: this.options.parent
}); });
}, },
...@@ -76,7 +76,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) { ...@@ -76,7 +76,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
this.$el.find('.transcripts-status') this.$el.find('.transcripts-status')
.removeClass('is-invisible') .removeClass('is-invisible')
.find(this.elClass).html(template({ .find(this.elClass).html(template({
component_id: encodeURIComponent(this.component_id), component_locator: encodeURIComponent(this.component_locator),
html5_list: html5List, html5_list: html5List,
grouped_list: groupedList, grouped_list: groupedList,
subs_id: (params) ? params.subs: '' subs_id: (params) ? params.subs: ''
...@@ -204,7 +204,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) { ...@@ -204,7 +204,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
*/ */
processCommand: function (action, errorMessage, videoId) { processCommand: function (action, errorMessage, videoId) {
var self = this, var self = this,
component_id = this.component_id, component_locator = this.component_locator,
videoList = this.options.parent.getVideoObjectsList(), videoList = this.options.parent.getVideoObjectsList(),
extraParam, xhr; extraParam, xhr;
...@@ -212,7 +212,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) { ...@@ -212,7 +212,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
extraParam = { html5_id: videoId }; extraParam = { html5_id: videoId };
} }
xhr = Utils.command(action, component_id, videoList, extraParam) xhr = Utils.command(action, component_locator, videoList, extraParam)
.done(function (resp) { .done(function (resp) {
var sub = resp.subs; var sub = resp.subs;
......
...@@ -46,7 +46,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) { ...@@ -46,7 +46,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) {
_.debounce(_.bind(this.inputHandler, this), this.inputDelay) _.debounce(_.bind(this.inputHandler, this), this.inputDelay)
); );
this.component_id = this.$el.closest('.component').data('id'); this.component_locator = this.$el.closest('.component').data('locator');
}, },
render: function () { render: function () {
...@@ -55,7 +55,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) { ...@@ -55,7 +55,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) {
.apply(this, arguments); .apply(this, arguments);
var self = this, var self = this,
component_id = this.$el.closest('.component').data('id'), component_locator = this.$el.closest('.component').data('locator'),
videoList = this.getVideoObjectsList(), videoList = this.getVideoObjectsList(),
showServerError = function (response) { showServerError = function (response) {
...@@ -82,7 +82,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) { ...@@ -82,7 +82,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) {
} }
// Check current state of Timed Transcripts. // Check current state of Timed Transcripts.
Utils.command('check', component_id, videoList) Utils.command('check', component_locator, videoList)
.done(function (resp) { .done(function (resp) {
var params = resp, var params = resp,
len = videoList.length, len = videoList.length,
......
...@@ -295,7 +295,7 @@ define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) { ...@@ -295,7 +295,7 @@ define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) {
* *
* @param {string} action Action that will be invoked on server. Is a part * @param {string} action Action that will be invoked on server. Is a part
* of url. * of url.
* @param {string} component_id Id of component. * @param {string} component_locator the locator of component.
* @param {array} videoList List of object with information about inserted * @param {array} videoList List of object with information about inserted
* urls. * urls.
* @param {object} extraParams Extra parameters that can be send to the * @param {object} extraParams Extra parameters that can be send to the
...@@ -314,7 +314,7 @@ define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) { ...@@ -314,7 +314,7 @@ define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) {
// _command() function. // _command() function.
var xhr = null; var xhr = null;
return function (action, component_id, videoList, extraParams) { return function (action, component_locator, videoList, extraParams) {
var params, data; var params, data;
if (extraParams) { if (extraParams) {
...@@ -326,7 +326,7 @@ define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) { ...@@ -326,7 +326,7 @@ define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) {
} }
data = $.extend( data = $.extend(
{ id: component_id }, { locator: component_locator },
{ videos: videoList }, { videos: videoList },
params params
); );
......
...@@ -10,6 +10,10 @@ ...@@ -10,6 +10,10 @@
&.is-shown { &.is-shown {
bottom: 0; bottom: 0;
} }
&.is-hiding {
bottom: -($ui-notification-height);
}
} }
} }
......
// studio - elements - xmodules // studio - elements - xmodules & xblocks
// ====================
// general - display mode (xblock-student_view or xmodule_display)
.xmodule_display, .xblock-student_view {
// font styling
i, em {
font-style: italic;
}
}
// ==================== // ====================
// Video Alpha // Video Alpha
......
...@@ -187,7 +187,7 @@ require(["domReady", "jquery", "gettext", "js/models/asset", "js/collections/ass ...@@ -187,7 +187,7 @@ require(["domReady", "jquery", "gettext", "js/models/asset", "js/collections/ass
<a href="#" class="close-button"><i class="icon-remove-sign"></i> <span class="sr">${_('close')}</span></a> <a href="#" class="close-button"><i class="icon-remove-sign"></i> <span class="sr">${_('close')}</span></a>
<div class="modal-body"> <div class="modal-body">
<h1 class="title">${_("Upload New File")}</h1> <h1 class="title">${_("Upload New File")}</h1>
<p class="file-name"></a> <p class="file-name">
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill"></div> <div class="progress-fill"></div>
</div> </div>
......
...@@ -33,7 +33,6 @@ require(["domReady!", "jquery", "js/collections/course_update", "js/models/modul ...@@ -33,7 +33,6 @@ require(["domReady!", "jquery", "js/collections/course_update", "js/models/modul
var editor = new CourseInfoEditView({ var editor = new CourseInfoEditView({
el: $('.main-wrapper'), el: $('.main-wrapper'),
model : new CourseInfoModel({ model : new CourseInfoModel({
courseId : '${context_course.location}',
updates : course_updates, updates : course_updates,
base_asset_url : '${base_asset_url}', base_asset_url : '${base_asset_url}',
handouts : course_handouts handouts : course_handouts
......
...@@ -9,12 +9,15 @@ ...@@ -9,12 +9,15 @@
<%block name="jsextra"> <%block name="jsextra">
<script type='text/javascript'> <script type='text/javascript'>
require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView) { require(["js/models/explicit_url", "coffee/src/views/tabs"], function(TabsModel, TabsEditView) {
var model = new TabsModel({
id: "${course_locator}",
explicit_url: "${course_locator.url_reverse('tabs')}"
});
new TabsEditView({ new TabsEditView({
el: $('.main-wrapper'), el: $('.main-wrapper'),
model: new Backbone.Model({ model: model,
id: '${locator}'
}),
mast: $('.wrapper-mast') mast: $('.wrapper-mast')
}); });
}); });
...@@ -61,8 +64,8 @@ require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView) ...@@ -61,8 +64,8 @@ require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView)
<div class="tab-list"> <div class="tab-list">
<ol class='components'> <ol class='components'>
% for id, locator in components: % for locator in components:
<li class="component" data-id="${id}" data-locator="${locator}"/> <li class="component" data-locator="${locator}"/>
% endfor % endfor
<li class="new-component-item"> <li class="new-component-item">
......
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
</div> </div>
<div class="sidebar"> <div class="sidebar">
<div class="unit-settings window id-holder" data-id="${subsection.location}"> <div class="unit-settings window id-holder" data-locator="${locator}">
<h4 class="header">${_("Subsection Settings")}</h4> <h4 class="header">${_("Subsection Settings")}</h4>
<div class="window-contents"> <div class="window-contents">
<div class="scheduled-date-input row"> <div class="scheduled-date-input row">
...@@ -115,7 +115,6 @@ require(["domReady!", "jquery", "js/models/location", "js/views/overview_assignm ...@@ -115,7 +115,6 @@ require(["domReady!", "jquery", "js/models/location", "js/views/overview_assignm
// but we really should change that behavior. // but we really should change that behavior.
if (!window.graderTypes) { if (!window.graderTypes) {
window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true}); window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true});
window.graderTypes.course_location = new Location('${parent_location}');
} }
$(".gradable-status").each(function(index, ele) { $(".gradable-status").each(function(index, ele) {
......
...@@ -3,12 +3,13 @@ ...@@ -3,12 +3,13 @@
<h2 class="title">Course Handouts</h2> <h2 class="title">Course Handouts</h2>
<%if (model.get('data') != null) { %> <%if (model.get('data') != null) { %>
<div class="handouts-content"> <div class="handouts-content">
<%= model.get('data') %>
</div> </div>
<% } else {%> <% } else {%>
<p>${_("You have no handouts defined")}</p> <p>${_("You have no handouts defined")}</p>
<% } %> <% } %>
<form class="edit-handouts-form" style="display: block;"> <form class="edit-handouts-form" style="display: block;">
<div class="message message-status error" name="handout_html_error" id="handout_error"><%=gettext("There is invalid code in your content. Please check to make sure it is valid HTML.")%></div>
<div class="row"> <div class="row">
<textarea class="handouts-content-editor text-editor"></textarea> <textarea class="handouts-content-editor text-editor"></textarea>
</div> </div>
......
...@@ -5,6 +5,6 @@ ...@@ -5,6 +5,6 @@
method="post" enctype="multipart/form-data"> method="post" enctype="multipart/form-data">
<input type="file" class="file-input" name="file" <input type="file" class="file-input" name="file"
accept="<%= _.map(ext, function(val){ return '.' + val; }).join(', ') %>"> accept="<%= _.map(ext, function(val){ return '.' + val; }).join(', ') %>">
<input type="hidden" name="id" value="<%= component_id %>"> <input type="hidden" name="locator" value="<%= component_locator %>">
<input type="hidden" name="video_list" value='<%= JSON.stringify(video_list) %>'> <input type="hidden" name="video_list" value='<%= JSON.stringify(video_list) %>'>
</form> </form>
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>"> <button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span> <span><%= gettext("Upload New Timed Transcript") %></span>
</button> </button>
<a class="action setting-download" href="/transcripts/download?id=<%= component_id %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download to Edit") %>"> <a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download to Edit") %>">
<span><%= gettext("Download to Edit") %></span> <span><%= gettext("Download to Edit") %></span>
</a> </a>
</div> </div>
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>"> <button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span> <span><%= gettext("Upload New Timed Transcript") %></span>
</button> </button>
<a class="action setting-download" href="/transcripts/download?id=<%= component_id %>" data-tooltip="<%= gettext("Download to Edit") %>"> <a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>" data-tooltip="<%= gettext("Download to Edit") %>">
<span><%= gettext("Download to Edit") %></span> <span><%= gettext("Download to Edit") %></span>
</a> </a>
</div> </div>
...@@ -27,7 +27,6 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -27,7 +27,6 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
// but we really should change that behavior. // but we really should change that behavior.
if (!window.graderTypes) { if (!window.graderTypes) {
window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true}); window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true});
window.graderTypes.course_location = new Location('${parent_location}');
} }
$(".gradable-status").each(function(index, ele) { $(".gradable-status").each(function(index, ele) {
...@@ -200,7 +199,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -200,7 +199,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
context_course.location.course_id, subsection.location, False, True context_course.location.course_id, subsection.location, False, True
) )
%> %>
<li class="courseware-subsection branch collapsed id-holder is-draggable" data-id="${subsection.location}" <li class="courseware-subsection branch collapsed id-holder is-draggable"
data-parent="${section_locator}" data-locator="${subsection_locator}"> data-parent="${section_locator}" data-locator="${subsection_locator}">
<%include file="widgets/_ui-dnd-indicator-before.html" /> <%include file="widgets/_ui-dnd-indicator-before.html" />
...@@ -208,7 +207,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -208,7 +207,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
<div class="section-item"> <div class="section-item">
<div class="details"> <div class="details">
<a href="#" data-tooltip="${_('Expand/collapse this subsection')}" class="expand-collapse-icon expand"></a> <a href="#" data-tooltip="${_('Expand/collapse this subsection')}" class="expand-collapse-icon expand"></a>
<a href="${reverse('edit_subsection', args=[subsection.location])}"> <a href="${subsection_locator.url_reverse('subsection')}">
<span class="folder-icon"></span> <span class="folder-icon"></span>
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span> <span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
</a> </a>
......
...@@ -4,10 +4,8 @@ ...@@ -4,10 +4,8 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! <%!
from contentstore import utils
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from django.core.urlresolvers import reverse
%> %>
<%block name="jsextra"> <%block name="jsextra">
...@@ -69,17 +67,20 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -69,17 +67,20 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<ol class="list-input"> <ol class="list-input">
<li class="field text is-not-editable" id="field-course-organization"> <li class="field text is-not-editable" id="field-course-organization">
<label for="course-organization">${_("Organization")}</label> <label for="course-organization">${_("Organization")}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="long" id="course-organization" value="[Course Organization]" readonly /> <input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="course-organization" readonly />
</li> </li>
<li class="field text is-not-editable" id="field-course-number"> <li class="field text is-not-editable" id="field-course-number">
<label for="course-number">${_("Course Number")}</label> <label for="course-number">${_("Course Number")}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="short" id="course-number" value="[Course No.]" readonly> <input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="short" id="course-number" readonly>
</li> </li>
<li class="field text is-not-editable" id="field-course-name"> <li class="field text is-not-editable" id="field-course-name">
<label for="course-name">${_("Course Name")}</label> <label for="course-name">${_("Course Name")}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="long" id="course-name" value="[Course Name]" readonly /> <input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="course-name" readonly />
</li> </li>
</ol> </ol>
...@@ -87,12 +88,14 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -87,12 +88,14 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<div class="note note-promotion note-promotion-courseURL has-actions"> <div class="note note-promotion note-promotion-courseURL has-actions">
<h3 class="title">${_("Course Summary Page")} <span class="tip">${_("(for student enrollment and access)")}</span></h3> <h3 class="title">${_("Course Summary Page")} <span class="tip">${_("(for student enrollment and access)")}</span></h3>
<div class="copy"> <div class="copy">
<p><a class="link-courseURL" rel="external" href="https:${utils.get_lms_link_for_about_page(course_location)}" />https:${utils.get_lms_link_for_about_page(course_location)}</a></p> <p><a class="link-courseURL" rel="external" href="https:${lms_link_for_about_page}">https:${lms_link_for_about_page}</a></p>
</div> </div>
<ul class="list-actions"> <ul class="list-actions">
<li class="action-item"> <li class="action-item">
<a title="${_('Send a note to students via email')}" href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20&quot;${context_course.display_name_with_default}&quot;,%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${utils.get_lms_link_for_about_page(course_location)}%20to%20enroll." class="action action-primary"><i class="icon-envelope-alt icon-inline"></i>${_("Invite your students")}</a> <a title="${_('Send a note to students via email')}"
href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20&quot;${context_course.display_name_with_default}&quot;,%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${lms_link_for_about_page}%20to%20enroll." class="action action-primary">
<i class="icon-envelope-alt icon-inline"></i>${_("Invite your students")}</a>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -199,7 +202,7 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -199,7 +202,7 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<%def name='overview_text()'><% <%def name='overview_text()'><%
a_link_start = '<a class="link-courseURL" rel="external" href="' a_link_start = '<a class="link-courseURL" rel="external" href="'
a_link_end = '">' + _("your course summary page") + '</a>' a_link_end = '">' + _("your course summary page") + '</a>'
a_link = a_link_start + utils.get_lms_link_for_about_page(course_location) + a_link_end a_link = a_link_start + lms_link_for_about_page + a_link_end
text = _("Introductions, prerequisites, FAQs that are used on %s (formatted in HTML)") % a_link text = _("Introductions, prerequisites, FAQs that are used on %s (formatted in HTML)") % a_link
%>${text}</%def> %>${text}</%def>
<span class="tip tip-stacked">${overview_text()}</span> <span class="tip tip-stacked">${overview_text()}</span>
...@@ -211,15 +214,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -211,15 +214,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<div class="current current-course-image"> <div class="current current-course-image">
% if context_course.course_image: % if context_course.course_image:
<span class="wrapper-course-image"> <span class="wrapper-course-image">
<img class="course-image" id="course-image" src="${utils.course_image_url(context_course)}" alt="${_('Course Image')}"/> <img class="course-image" id="course-image" src="${course_image_url}" alt="${_('Course Image')}"/>
</span> </span>
<% ctx_loc = context_course.location %> <span class="msg msg-help">
<span class="msg msg-help">${_("You can manage this image along with all of your other")} <a href='${upload_asset_url}'>${_("files &amp; uploads")}</a></span> ${_("You can manage this image along with all of your other <a href='{}'>files &amp; uploads</a>").format(upload_asset_url)}
</span>
% else: % else:
<span class="wrapper-course-image"> <span class="wrapper-course-image">
<img class="course-image placeholder" id="course-image" src="${utils.course_image_url(context_course)}" alt="${_('Course Image')}"/> <img class="course-image placeholder" id="course-image" src="${course_image_url}" alt="${_('Course Image')}"/>
</span> </span>
<span class="msg msg-empty">${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")}</span> <span class="msg msg-empty">${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")}</span>
% endif % endif
...@@ -286,16 +290,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -286,16 +290,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<div class="bit"> <div class="bit">
% if context_course: % if context_course:
<% <%
ctx_loc = context_course.location course_team_url = course_locator.url_reverse('course_team/', '')
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) grading_config_url = course_locator.url_reverse('settings/grading/')
course_team_url = location.url_reverse('course_team/', '') advanced_config_url = course_locator.url_reverse('settings/advanced/')
%> %>
<h3 class="title-3">${_("Other Course Settings")}</h3> <h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related"> <nav class="nav-related">
<ul> <ul>
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a></li> <li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li> <li class="nav-item"><a href="${advanced_config_url}">${_("Advanced Settings")}</a></li>
</ul> </ul>
</nav> </nav>
% endif % endif
......
<%inherit file="base.html" /> <%inherit file="base.html" />
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! <%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from contentstore import utils from contentstore import utils
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from django.core.urlresolvers import reverse
%> %>
<%block name="title">${_("Advanced Settings")}</%block> <%block name="title">${_("Advanced Settings")}</%block>
<%block name="bodyclass">is-signedin course advanced view-settings</%block> <%block name="bodyclass">is-signedin course advanced view-settings</%block>
...@@ -28,7 +26,7 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting ...@@ -28,7 +26,7 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting
// proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern // proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern
var advancedModel = new AdvancedSettingsModel(${advanced_dict | n}, {parse: true}); var advancedModel = new AdvancedSettingsModel(${advanced_dict | n}, {parse: true});
advancedModel.url = "${reverse('course_advanced_settings_updates', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}"; advancedModel.url = "${advanced_settings_url}";
var editor = new AdvancedSettingsView({ var editor = new AdvancedSettingsView({
el: $('.settings-advanced'), el: $('.settings-advanced'),
...@@ -91,13 +89,15 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting ...@@ -91,13 +89,15 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting
<% <%
ctx_loc = context_course.location ctx_loc = context_course.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)
details_url = location.url_reverse('settings/details/')
grading_url = location.url_reverse('settings/grading/')
course_team_url = location.url_reverse('course_team/', '') course_team_url = location.url_reverse('course_team/', '')
%> %>
<h3 class="title-3">${_("Other Course Settings")}</h3> <h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related"> <nav class="nav-related">
<ul> <ul>
<li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Details &amp; Schedule")}</a></li> <li class="nav-item"><a href="${details_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a></li> <li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
</ul> </ul>
</nav> </nav>
......
...@@ -7,7 +7,6 @@ ...@@ -7,7 +7,6 @@
from contentstore import utils from contentstore import utils
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from django.core.urlresolvers import reverse
%> %>
<%block name="header_extras"> <%block name="header_extras">
...@@ -28,9 +27,11 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings ...@@ -28,9 +27,11 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
$("label").removeClass("is-focused"); $("label").removeClass("is-focused");
}); });
var model = new CourseGradingPolicyModel(${course_details|n},{parse:true});
model.urlRoot = '${grading_url}';
var editor = new GradingView({ var editor = new GradingView({
el: $('.settings-grading'), el: $('.settings-grading'),
model : new CourseGradingPolicyModel(${course_details|n},{parse:true}) model : model
}); });
editor.render(); editor.render();
...@@ -137,16 +138,16 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings ...@@ -137,16 +138,16 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
<div class="bit"> <div class="bit">
% if context_course: % if context_course:
<% <%
ctx_loc = context_course.location course_team_url = course_locator.url_reverse('course_team/')
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) advanced_settings_url = course_locator.url_reverse('settings/advanced/')
course_team_url = location.url_reverse('course_team/', '') detailed_settings_url = course_locator.url_reverse('settings/details/')
%> %>
<h3 class="title-3">${_("Other Course Settings")}</h3> <h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related"> <nav class="nav-related">
<ul> <ul>
<li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Details &amp; Schedule")}</a></li> <li class="nav-item"><a href="${detailed_settings_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li> <li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
</ul> </ul>
</nav> </nav>
% endif % endif
......
...@@ -34,7 +34,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -34,7 +34,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
</%block> </%block>
<%block name="content"> <%block name="content">
<div class="main-wrapper edit-state-${unit_state}" data-id="${unit_location}" data-locator="${unit_locator}"> <div class="main-wrapper edit-state-${unit_state}" data-locator="${unit_locator}">
<div class="inner-wrapper"> <div class="inner-wrapper">
<div class="alert editing-draft-alert"> <div class="alert editing-draft-alert">
<p class="alert-message"><strong>${_("You are editing a draft.")}</strong> <p class="alert-message"><strong>${_("You are editing a draft.")}</strong>
...@@ -48,8 +48,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -48,8 +48,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
<article class="unit-body window"> <article class="unit-body window">
<p class="unit-name-input"><label>${_("Display Name:")}</label><input type="text" value="${unit.display_name_with_default | h}" class="unit-display-name-input" /></p> <p class="unit-name-input"><label>${_("Display Name:")}</label><input type="text" value="${unit.display_name_with_default | h}" class="unit-display-name-input" /></p>
<ol class="components"> <ol class="components">
% for id, locator in components: % for locator in components:
<li class="component" data-id="${id}" data-locator="${locator}"/> <li class="component" data-locator="${locator}"/>
% endfor % endfor
<li class="new-component-item adding"> <li class="new-component-item adding">
<div class="new-component"> <div class="new-component">
...@@ -135,6 +135,13 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -135,6 +135,13 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
</article> </article>
</div> </div>
<%
ctx_loc = context_course.location
index_url = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True).url_reverse('course')
subsection_url = loc_mapper().translate_location(
ctx_loc.course_id, subsection.location, False, True
).url_reverse('subsection')
%>
<div class="sidebar"> <div class="sidebar">
<div class="unit-settings window"> <div class="unit-settings window">
<h4 class="header">${_("Unit Settings")}</h4> <h4 class="header">${_("Unit Settings")}</h4>
...@@ -157,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -157,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
% endif % endif
${_("with the subsection {link_start}{name}{link_end}").format( ${_("with the subsection {link_start}{name}{link_end}").format(
name=subsection.display_name_with_default, name=subsection.display_name_with_default,
link_start='<a href="{url}">'.format(url=reverse('edit_subsection', kwargs={'location': subsection.location})), link_start='<a href="{url}">'.format(url=subsection_url),
link_end='</a>', link_end='</a>',
)} )}
</p> </p>
...@@ -175,19 +182,15 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -175,19 +182,15 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
<div class="row wrapper-unit-id"> <div class="row wrapper-unit-id">
<p class="unit-id"> <p class="unit-id">
<span class="label">${_("Unit Identifier:")}</span> <span class="label">${_("Unit Identifier:")}</span>
<input type="text" class="url value" value="${unit.location.name}" disabled /> <input type="text" class="url value" value="${unit.location.name}" readonly />
</p> </p>
</div> </div>
<ol> <ol>
<li> <li>
<%
ctx_loc = context_course.location
index_url = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True).url_reverse('course/', '')
%>
<a href="${index_url}" class="section-item">${section.display_name_with_default}</a> <a href="${index_url}" class="section-item">${section.display_name_with_default}</a>
<ol> <ol>
<li> <li>
<a href="${reverse('edit_subsection', args=[subsection.location])}" class="section-item"> <a href="${subsection_url}" class="section-item">
<span class="folder-icon"></span> <span class="folder-icon"></span>
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span> <span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
</a> </a>
......
...@@ -16,13 +16,17 @@ ...@@ -16,13 +16,17 @@
<% <%
ctx_loc = context_course.location ctx_loc = context_course.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)
index_url = location.url_reverse('course/') index_url = location.url_reverse('course')
checklists_url = location.url_reverse('checklists/') checklists_url = location.url_reverse('checklists')
course_team_url = location.url_reverse('course_team/') course_team_url = location.url_reverse('course_team')
assets_url = location.url_reverse('assets/') assets_url = location.url_reverse('assets')
import_url = location.url_reverse('import/') import_url = location.url_reverse('import')
course_info_url = location.url_reverse('course_info/') course_info_url = location.url_reverse('course_info')
export_url = location.url_reverse('export/', '') export_url = location.url_reverse('export')
settings_url = location.url_reverse('settings/details/')
grading_url = location.url_reverse('settings/grading/')
advanced_settings_url = location.url_reverse('settings/advanced/')
tabs_url = location.url_reverse('tabs')
%> %>
<h2 class="info-course"> <h2 class="info-course">
<span class="sr">${_("Current Course:")}</span> <span class="sr">${_("Current Course:")}</span>
...@@ -48,7 +52,7 @@ ...@@ -48,7 +52,7 @@
<a href="${course_info_url}">${_("Updates")}</a> <a href="${course_info_url}">${_("Updates")}</a>
</li> </li>
<li class="nav-item nav-course-courseware-pages"> <li class="nav-item nav-course-courseware-pages">
<a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}">${_("Static Pages")}</a> <a href="${tabs_url}">${_("Static Pages")}</a>
</li> </li>
<li class="nav-item nav-course-courseware-uploads"> <li class="nav-item nav-course-courseware-uploads">
<a href="${assets_url}">${_("Files &amp; Uploads")}</a> <a href="${assets_url}">${_("Files &amp; Uploads")}</a>
...@@ -68,16 +72,16 @@ ...@@ -68,16 +72,16 @@
<div class="nav-sub"> <div class="nav-sub">
<ul> <ul>
<li class="nav-item nav-course-settings-schedule"> <li class="nav-item nav-course-settings-schedule">
<a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Schedule &amp; Details")}</a> <a href="${settings_url}">${_("Schedule &amp; Details")}</a>
</li> </li>
<li class="nav-item nav-course-settings-grading"> <li class="nav-item nav-course-settings-grading">
<a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a> <a href="${grading_url}">${_("Grading")}</a>
</li> </li>
<li class="nav-item nav-course-settings-team"> <li class="nav-item nav-course-settings-team">
<a href="${course_team_url}">${_("Course Team")}</a> <a href="${course_team_url}">${_("Course Team")}</a>
</li> </li>
<li class="nav-item nav-course-settings-advanced"> <li class="nav-item nav-course-settings-advanced">
<a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a> <a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
</li> </li>
</ul> </ul>
</div> </div>
......
...@@ -39,7 +39,7 @@ ...@@ -39,7 +39,7 @@
<div class="row"> <div class="row">
<h6>${_("Heading 1")}</h6> <h6>${_("Heading 1")}</h6>
<div class="col sample heading-1"> <div class="col sample heading-1">
<img src="${static.url("/img/header-example.png")}" /> <img src="${static.url("img/header-example.png")}" />
</div> </div>
<div class="col"> <div class="col">
<pre><code>H1 <pre><code>H1
...@@ -75,7 +75,9 @@ ...@@ -75,7 +75,9 @@
<img src="${static.url("img/string-example.png")}" /> <img src="${static.url("img/string-example.png")}" />
</div> </div>
<div class="col"> <div class="col">
<pre><code>= dog</code></pre> <pre><code>= dog
or= cat
or= mouse</code></pre>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
......
<%!
from xmodule.modulestore.django import loc_mapper
%>
% if context_course:
<%
ctx_loc = context_course.location
locator = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
%>
% endif
% if settings.MITX_FEATURES.get('SEGMENT_IO'): % if settings.MITX_FEATURES.get('SEGMENT_IO'):
<!-- begin Segment.io --> <!-- begin Segment.io -->
<script type="text/javascript"> <script type="text/javascript">
// if inside course, inject the course location into the JS namespace // if inside course, inject the course location into the JS namespace
%if context_course: %if context_course:
var course_location_analytics = "${context_course.location}"; var course_location_analytics = "${locator}";
%endif %endif
var analytics=analytics||[];analytics.load=function(e){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=("https:"===document.location.protocol?"https://":"http://")+"d2dq2ahtl5zl1z.cloudfront.net/analytics.js/v1/"+e+"/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n);var r=function(e){return function(){analytics.push([e].concat(Array.prototype.slice.call(arguments,0)))}},i=["identify","track","trackLink","trackForm","trackClick","trackSubmit","pageview","ab","alias","ready"];for(var s=0;s<i.length;s++)analytics[i[s]]=r(i[s])}; var analytics=analytics||[];analytics.load=function(e){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=("https:"===document.location.protocol?"https://":"http://")+"d2dq2ahtl5zl1z.cloudfront.net/analytics.js/v1/"+e+"/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n);var r=function(e){return function(){analytics.push([e].concat(Array.prototype.slice.call(arguments,0)))}},i=["identify","track","trackLink","trackForm","trackClick","trackSubmit","pageview","ab","alias","ready"];for(var s=0;s<i.length;s++)analytics[i[s]]=r(i[s])};
...@@ -22,7 +33,7 @@ ...@@ -22,7 +33,7 @@
<!-- dummy segment.io --> <!-- dummy segment.io -->
<script type="text/javascript"> <script type="text/javascript">
%if context_course: %if context_course:
var course_location_analytics = "${context_course.location}"; var course_location_analytics = "${locator}";
%endif %endif
var analytics = { var analytics = {
"track": function() {} "track": function() {}
......
...@@ -31,7 +31,7 @@ This def will enumerate through a passed in subsection and list all of the units ...@@ -31,7 +31,7 @@ This def will enumerate through a passed in subsection and list all of the units
selected_class = '' selected_class = ''
%> %>
<div class="section-item ${selected_class}"> <div class="section-item ${selected_class}">
<a href="${reverse('edit_unit', args=[unit.location])}" class="${unit_state}-item"> <a href="${unit_locator.url_reverse('unit')}" class="${unit_state}-item">
<span class="${unit.scope_ids.block_type}-icon"></span> <span class="${unit.scope_ids.block_type}-icon"></span>
<span class="unit-name">${unit.display_name_with_default}</span> <span class="unit-name">${unit.display_name_with_default}</span>
</a> </a>
......
...@@ -11,10 +11,6 @@ from ratelimitbackend import admin ...@@ -11,10 +11,6 @@ from ratelimitbackend import admin
admin.autodiscover() admin.autodiscover()
urlpatterns = patterns('', # nopep8 urlpatterns = patterns('', # nopep8
url(r'^$', 'contentstore.views.howitworks', name='homepage'),
url(r'^edit/(?P<location>.*?)$', 'contentstore.views.edit_unit', name='edit_unit'),
url(r'^subsection/(?P<location>.*?)$', 'contentstore.views.edit_subsection', name='edit_subsection'),
url(r'^preview_component/(?P<location>.*?)$', 'contentstore.views.preview_component', name='preview_component'),
url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'), url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'),
url(r'^transcripts/download$', 'contentstore.views.download_transcripts', name='download_transcripts'), url(r'^transcripts/download$', 'contentstore.views.download_transcripts', name='download_transcripts'),
...@@ -24,35 +20,9 @@ urlpatterns = patterns('', # nopep8 ...@@ -24,35 +20,9 @@ urlpatterns = patterns('', # nopep8
url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_transcripts'), url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_transcripts'),
url(r'^transcripts/save$', 'contentstore.views.save_transcripts', name='save_transcripts'), url(r'^transcripts/save$', 'contentstore.views.save_transcripts', name='save_transcripts'),
url(r'^create_draft$', 'contentstore.views.create_draft', name='create_draft'),
url(r'^publish_draft$', 'contentstore.views.publish_draft', name='publish_draft'),
url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'),
url(r'^reorder_static_tabs', 'contentstore.views.reorder_static_tabs', name='reorder_static_tabs'),
url(r'^preview/xblock/(?P<usage_id>.*?)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>[^/]*))?$', url(r'^preview/xblock/(?P<usage_id>.*?)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>[^/]*))?$',
'contentstore.views.preview_handler', name='preview_handler'), 'contentstore.views.preview_handler', name='preview_handler'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)$',
'contentstore.views.get_course_settings', name='settings_details'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$',
'contentstore.views.course_config_graders_page', name='settings_grading'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)/section/(?P<section>[^/]+).*$',
'contentstore.views.course_settings_updates', name='course_settings'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)/(?P<grader_index>.*)$',
'contentstore.views.course_grader_updates', name='course_settings'),
# This is the URL to initially render the course advanced settings.
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)$',
'contentstore.views.course_config_advanced_page', name='course_advanced_settings'),
# This is the URL used by BackBone for updating and re-fetching the model.
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)/update.*$',
'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/(?P<category>[^/]+)/(?P<name>[^/]+)/gradeas.*$',
'contentstore.views.assignment_type_update', name='assignment_type_update'),
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
'contentstore.views.edit_tabs', name='edit_tabs'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)$',
'contentstore.views.textbook_index', name='textbook_index'), 'contentstore.views.textbook_index', name='textbook_index'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/new$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/new$',
...@@ -79,18 +49,12 @@ urlpatterns = patterns('', # nopep8 ...@@ -79,18 +49,12 @@ urlpatterns = patterns('', # nopep8
# User creation and updating views # User creation and updating views
urlpatterns += patterns( urlpatterns += patterns(
'', '',
url(r'^howitworks$', 'contentstore.views.howitworks', name='howitworks'),
url(r'^signup$', 'contentstore.views.signup', name='signup'),
url(r'^create_account$', 'student.views.create_account'), url(r'^create_account$', 'student.views.create_account'),
url(r'^activate/(?P<key>[^/]*)$', 'student.views.activate_account', name='activate'), url(r'^activate/(?P<key>[^/]*)$', 'student.views.activate_account', name='activate'),
# form page
url(r'^login$', 'contentstore.views.old_login_redirect', name='old_login'),
url(r'^signin$', 'contentstore.views.login_page', name='login'),
# ajax view that actually does the work # ajax view that actually does the work
url(r'^login_post$', 'student.views.login_user', name='login_post'), url(r'^login_post$', 'student.views.login_user', name='login_post'),
url(r'^logout$', 'student.views.logout_user', name='logout'), url(r'^logout$', 'student.views.logout_user', name='logout'),
) )
...@@ -98,7 +62,12 @@ urlpatterns += patterns( ...@@ -98,7 +62,12 @@ urlpatterns += patterns(
urlpatterns += patterns( urlpatterns += patterns(
'contentstore.views', 'contentstore.views',
url(r'^$', 'howitworks', name='homepage'),
url(r'^howitworks$', 'howitworks'),
url(r'^signup$', 'signup', name='signup'),
url(r'^signin$', 'login_page', name='login'),
url(r'^request_course_creator$', 'request_course_creator'), url(r'^request_course_creator$', 'request_course_creator'),
# (?ix) == ignore case and verbose (multiline regex) # (?ix) == ignore case and verbose (multiline regex)
url(r'(?ix)^course_team/{}(/)?(?P<email>.+)?$'.format(parsers.URL_RE_SOURCE), 'course_team_handler'), url(r'(?ix)^course_team/{}(/)?(?P<email>.+)?$'.format(parsers.URL_RE_SOURCE), 'course_team_handler'),
url(r'(?ix)^course_info/{}$'.format(parsers.URL_RE_SOURCE), 'course_info_handler'), url(r'(?ix)^course_info/{}$'.format(parsers.URL_RE_SOURCE), 'course_info_handler'),
...@@ -107,6 +76,8 @@ urlpatterns += patterns( ...@@ -107,6 +76,8 @@ urlpatterns += patterns(
'course_info_update_handler' 'course_info_update_handler'
), ),
url(r'(?ix)^course($|/){}$'.format(parsers.URL_RE_SOURCE), 'course_handler'), url(r'(?ix)^course($|/){}$'.format(parsers.URL_RE_SOURCE), 'course_handler'),
url(r'(?ix)^subsection($|/){}$'.format(parsers.URL_RE_SOURCE), 'subsection_handler'),
url(r'(?ix)^unit($|/){}$'.format(parsers.URL_RE_SOURCE), 'unit_handler'),
url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'), url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'),
url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'), url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'),
url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'), url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'),
...@@ -114,6 +85,10 @@ urlpatterns += patterns( ...@@ -114,6 +85,10 @@ urlpatterns += patterns(
url(r'(?ix)^import_status/{}/(?P<filename>.+)$'.format(parsers.URL_RE_SOURCE), 'import_status_handler'), url(r'(?ix)^import_status/{}/(?P<filename>.+)$'.format(parsers.URL_RE_SOURCE), 'import_status_handler'),
url(r'(?ix)^export/{}$'.format(parsers.URL_RE_SOURCE), 'export_handler'), url(r'(?ix)^export/{}$'.format(parsers.URL_RE_SOURCE), 'export_handler'),
url(r'(?ix)^xblock($|/){}$'.format(parsers.URL_RE_SOURCE), 'xblock_handler'), url(r'(?ix)^xblock($|/){}$'.format(parsers.URL_RE_SOURCE), 'xblock_handler'),
url(r'(?ix)^tabs/{}$'.format(parsers.URL_RE_SOURCE), 'tabs_handler'),
url(r'(?ix)^settings/details/{}$'.format(parsers.URL_RE_SOURCE), 'settings_handler'),
url(r'(?ix)^settings/grading/{}(/)?(?P<grader_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'grading_handler'),
url(r'(?ix)^settings/advanced/{}$'.format(parsers.URL_RE_SOURCE), 'advanced_settings_handler'),
) )
js_info_dict = { js_info_dict = {
......
"""
Provides unit tests for SSL based authentication portions
of the external_auth app.
"""
import unittest
from django.conf import settings
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import Client
from django.test.client import RequestFactory
from django.test.utils import override_settings
from external_auth.models import ExternalAuthMap
import external_auth.views
MITX_FEATURES_WITH_SSL_AUTH = settings.MITX_FEATURES.copy()
MITX_FEATURES_WITH_SSL_AUTH['AUTH_USE_MIT_CERTIFICATES'] = True
MITX_FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP = MITX_FEATURES_WITH_SSL_AUTH.copy()
MITX_FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP['AUTH_USE_MIT_CERTIFICATES_IMMEDIATE_SIGNUP'] = True
@override_settings(MITX_FEATURES=MITX_FEATURES_WITH_SSL_AUTH)
class SSLClientTest(TestCase):
"""
Tests SSL Authentication code sections of external_auth
"""
AUTH_DN = '/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}'
USER_NAME = 'test_user_ssl'
USER_EMAIL = 'test_user_ssl@EDX.ORG'
def _create_ssl_request(self, url):
"""Creates a basic request for SSL use."""
request = self.factory.get(url)
request.META['SSL_CLIENT_S_DN'] = self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)
request.user = AnonymousUser()
middleware = SessionMiddleware()
middleware.process_request(request)
request.session.save()
return request
def setUp(self):
"""Setup test case by adding primary user."""
super(SSLClientTest, self).setUp()
self.client = Client()
self.factory = RequestFactory()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_ssl_login_with_signup_lms(self):
"""
Validate that an SSL login creates an eamap user and
redirects them to the signup page.
"""
response = external_auth.views.ssl_login(self._create_ssl_request('/'))
# Response should contain template for signup form, eamap should have user, and internal
# auth should not have a user
self.assertIn('<form role="form" id="register-form" method="post"', response.content)
try:
ExternalAuthMap.objects.get(external_id=self.USER_EMAIL)
except ExternalAuthMap.DoesNotExist, ex:
self.fail('User did not get properly added to external auth map, exception was {0}'.format(str(ex)))
with self.assertRaises(User.DoesNotExist):
User.objects.get(email=self.USER_EMAIL)
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
@unittest.skip
def test_ssl_login_with_signup_cms(self):
"""
Validate that an SSL login creates an eamap user and
redirects them to the signup page on CMS.
This currently is failing and should be resolved to passing at
some point. using skip here instead of expectFailure because
of an issue with nose.
"""
self.client.get(
reverse('contentstore.views.login_page'),
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)
)
try:
ExternalAuthMap.objects.get(external_id=self.USER_EMAIL)
except ExternalAuthMap.DoesNotExist, ex:
self.fail('User did not get properly added to external auth map, exception was {0}'.format(str(ex)))
with self.assertRaises(User.DoesNotExist):
User.objects.get(email=self.USER_EMAIL)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@override_settings(MITX_FEATURES=MITX_FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP)
def test_ssl_login_without_signup_lms(self):
"""
Test IMMEDIATE_SIGNUP feature flag and ensure the user account is automatically created
and the user is redirected to slash.
"""
external_auth.views.ssl_login(self._create_ssl_request('/'))
# Assert our user exists in both eamap and Users, and that we are logged in
try:
ExternalAuthMap.objects.get(external_id=self.USER_EMAIL)
except ExternalAuthMap.DoesNotExist, ex:
self.fail('User did not get properly added to external auth map, exception was {0}'.format(str(ex)))
try:
User.objects.get(email=self.USER_EMAIL)
except ExternalAuthMap.DoesNotExist, ex:
self.fail('User did not get properly added to internal users, exception was {0}'.format(str(ex)))
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
@override_settings(MITX_FEATURES=MITX_FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP)
@unittest.skip
def test_ssl_login_without_signup_cms(self):
"""
Test IMMEDIATE_SIGNUP feature flag and ensure the user account is
automatically created on CMS.
This currently is failing and should be resolved to passing at
some point. using skip here instead of expectFailure because
of an issue with nose.
"""
self.client.get(
reverse('contentstore.views.login_page'),
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)
)
# Assert our user exists in both eamap and Users, and that we are logged in
try:
ExternalAuthMap.objects.get(external_id=self.USER_EMAIL)
except ExternalAuthMap.DoesNotExist, ex:
self.fail('User did not get properly added to external auth map, exception was {0}'.format(str(ex)))
try:
User.objects.get(email=self.USER_EMAIL)
except ExternalAuthMap.DoesNotExist, ex:
self.fail('User did not get properly added to internal users, exception was {0}'.format(str(ex)))
...@@ -21,7 +21,7 @@ from django.core.exceptions import ValidationError ...@@ -21,7 +21,7 @@ from django.core.exceptions import ValidationError
if settings.MITX_FEATURES.get('AUTH_USE_CAS'): if settings.MITX_FEATURES.get('AUTH_USE_CAS'):
from django_cas.views import login as django_cas_login from django_cas.views import login as django_cas_login
from student.models import UserProfile, TestCenterUser, TestCenterRegistration from student.models import UserProfile
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden
from django.utils.http import urlquote, is_safe_url from django.utils.http import urlquote, is_safe_url
...@@ -250,6 +250,18 @@ def _signup(request, eamap): ...@@ -250,6 +250,18 @@ def _signup(request, eamap):
# save this for use by student.views.create_account # save this for use by student.views.create_account
request.session['ExternalAuthMap'] = eamap request.session['ExternalAuthMap'] = eamap
if settings.MITX_FEATURES.get('AUTH_USE_MIT_CERTIFICATES_IMMEDIATE_SIGNUP', ''):
# do signin immediately, by calling create_account, instead of asking
# student to fill in form. MIT students already have information filed.
username = eamap.external_email.split('@', 1)[0]
username = username.replace('.', '_')
post_vars = dict(username=username,
honor_code=u'true',
terms_of_service=u'true')
log.info('doing immediate signup for %s, params=%s', username, post_vars)
student.views.create_account(request, post_vars)
return redirect('/')
# default conjoin name, no spaces, flattened to ascii b/c django can't handle unicode usernames, sadly # default conjoin name, no spaces, flattened to ascii b/c django can't handle unicode usernames, sadly
# but this only affects username, not fullname # but this only affects username, not fullname
username = re.sub(r'\s', '', _flatten_to_ascii(eamap.external_name), flags=re.UNICODE) username = re.sub(r'\s', '', _flatten_to_ascii(eamap.external_name), flags=re.UNICODE)
...@@ -880,146 +892,7 @@ def provider_xrds(request): ...@@ -880,146 +892,7 @@ def provider_xrds(request):
return response return response
#-------------------
# Pearson
#-------------------
def course_from_id(course_id): def course_from_id(course_id):
"""Return the CourseDescriptor corresponding to this course_id""" """Return the CourseDescriptor corresponding to this course_id"""
course_loc = CourseDescriptor.id_to_location(course_id) course_loc = CourseDescriptor.id_to_location(course_id)
return modulestore().get_instance(course_id, course_loc) return modulestore().get_instance(course_id, course_loc)
@csrf_exempt
def test_center_login(request):
''' Log in students taking exams via Pearson
Takes a POST request that contains the following keys:
- code - a security code provided by Pearson
- clientCandidateID
- registrationID
- exitURL - the url that we redirect to once we're done
- vueExamSeriesCode - a code that indicates the exam that we're using
'''
# Imports from lms/djangoapps/courseware -- these should not be
# in a common djangoapps.
from courseware.views import get_module_for_descriptor, jump_to
from courseware.model_data import FieldDataCache
# errors are returned by navigating to the error_url, adding a query parameter named "code"
# which contains the error code describing the exceptional condition.
def makeErrorURL(error_url, error_code):
log.error("generating error URL with error code {}".format(error_code))
return "{}?code={}".format(error_url, error_code)
# get provided error URL, which will be used as a known prefix for returning error messages to the
# Pearson shell.
error_url = request.POST.get("errorURL")
# TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson
# with the code we calculate for the same parameters.
if 'code' not in request.POST:
return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode"))
code = request.POST.get("code")
# calculate SHA for query string
# TODO: figure out how to get the original query string, so we can hash it and compare.
if 'clientCandidateID' not in request.POST:
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID"))
client_candidate_id = request.POST.get("clientCandidateID")
# TODO: check remaining parameters, and maybe at least log if they're not matching
# expected values....
# registration_id = request.POST.get("registrationID")
# exit_url = request.POST.get("exitURL")
# find testcenter_user that matches the provided ID:
try:
testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id)
except TestCenterUser.DoesNotExist:
AUDIT_LOG.error("not able to find demographics for cand ID {}".format(client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID"))
AUDIT_LOG.info("Attempting to log in test-center user '{}' for test of cand {}".format(testcenteruser.user.username, client_candidate_id))
# find testcenter_registration that matches the provided exam code:
# Note that we could rely in future on either the registrationId or the exam code,
# or possibly both. But for now we know what to do with an ExamSeriesCode,
# while we currently have no record of RegistrationID values at all.
if 'vueExamSeriesCode' not in request.POST:
# we are not allowed to make up a new error code, according to Pearson,
# so instead of "missingExamSeriesCode", we use a valid one that is
# inaccurate but at least distinct. (Sigh.)
AUDIT_LOG.error("missing exam series code for cand ID {}".format(client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID"))
exam_series_code = request.POST.get('vueExamSeriesCode')
registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code)
if not registrations:
AUDIT_LOG.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned"))
# TODO: figure out what to do if there are more than one registrations....
# for now, just take the first...
registration = registrations[0]
course_id = registration.course_id
course = course_from_id(course_id) # assume it will be found....
if not course:
AUDIT_LOG.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"))
exam = course.get_test_center_exam(exam_series_code)
if not exam:
AUDIT_LOG.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"))
location = exam.exam_url
log.info("Proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location))
# check if the test has already been taken
timelimit_descriptor = modulestore().get_instance(course_id, Location(location))
if not timelimit_descriptor:
log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location))
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"))
timelimit_module_cache = FieldDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
timelimit_descriptor, depth=None)
timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
timelimit_module_cache, course_id, position=None)
if not timelimit_module.category == 'timelimit':
log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location))
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"))
if timelimit_module and timelimit_module.has_ended:
AUDIT_LOG.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at))
return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken"))
# check if we need to provide an accommodation:
time_accommodation_mapping = {'ET12ET': 'ADDHALFTIME',
'ET30MN': 'ADD30MIN',
'ETDBTM': 'ADDDOUBLE', }
time_accommodation_code = None
for code in registration.get_accommodation_codes():
if code in time_accommodation_mapping:
time_accommodation_code = time_accommodation_mapping[code]
if time_accommodation_code:
timelimit_module.accommodation_code = time_accommodation_code
AUDIT_LOG.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code))
# UGLY HACK!!!
# Login assumes that authentication has occurred, and that there is a
# backend annotation on the user object, indicating which backend
# against which the user was authenticated. We're authenticating here
# against the registration entry, and assuming that the request given
# this information is correct, we allow the user to be logged in
# without a password. This could all be formalized in a backend object
# that does the above checking.
# TODO: (brian) create a backend class to do this.
# testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass")
login(request, testcenteruser.user)
AUDIT_LOG.info("Logged in user '{}' for test of cand {} on exam {} for course {}: URL = {}".format(testcenteruser.user.username, client_candidate_id, exam_series_code, course_id, location))
# And start the test:
return jump_to(request, course_id, location)
from optparse import make_option
from json import dump
from datetime import datetime
from django.core.management.base import BaseCommand
from student.models import TestCenterRegistration
class Command(BaseCommand):
args = '<output JSON file>'
help = """
Dump information as JSON from TestCenterRegistration tables, including username and status.
"""
option_list = BaseCommand.option_list + (
make_option('--course_id',
action='store',
dest='course_id',
help='Specify a particular course.'),
make_option('--exam_series_code',
action='store',
dest='exam_series_code',
default=None,
help='Specify a particular exam, using the Pearson code'),
make_option('--accommodation_pending',
action='store_true',
dest='accommodation_pending',
default=False,
),
)
def handle(self, *args, **options):
if len(args) < 1:
outputfile = datetime.utcnow().strftime("pearson-dump-%Y%m%d-%H%M%S.json")
else:
outputfile = args[0]
# construct the query object to dump:
registrations = TestCenterRegistration.objects.all()
if 'course_id' in options and options['course_id']:
registrations = registrations.filter(course_id=options['course_id'])
if 'exam_series_code' in options and options['exam_series_code']:
registrations = registrations.filter(exam_series_code=options['exam_series_code'])
# collect output:
output = []
for registration in registrations:
if 'accommodation_pending' in options and options['accommodation_pending'] and not registration.accommodation_is_pending:
continue
record = {'username': registration.testcenter_user.user.username,
'email': registration.testcenter_user.email,
'first_name': registration.testcenter_user.first_name,
'last_name': registration.testcenter_user.last_name,
'client_candidate_id': registration.client_candidate_id,
'client_authorization_id': registration.client_authorization_id,
'course_id': registration.course_id,
'exam_series_code': registration.exam_series_code,
'accommodation_request': registration.accommodation_request,
'accommodation_code': registration.accommodation_code,
'registration_status': registration.registration_status(),
'demographics_status': registration.demographics_status(),
'accommodation_status': registration.accommodation_status(),
}
if len(registration.upload_error_message) > 0:
record['registration_error'] = registration.upload_error_message
if len(registration.testcenter_user.upload_error_message) > 0:
record['demographics_error'] = registration.testcenter_user.upload_error_message
if registration.needs_uploading:
record['needs_uploading'] = True
output.append(record)
# dump output:
with open(outputfile, 'w') as outfile:
dump(output, outfile, indent=2)
import csv
import os
from collections import OrderedDict
from datetime import datetime
from optparse import make_option
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from student.models import TestCenterUser
from pytz import UTC
class Command(BaseCommand):
CSV_TO_MODEL_FIELDS = OrderedDict([
# Skipping optional field CandidateID
("ClientCandidateID", "client_candidate_id"),
("FirstName", "first_name"),
("LastName", "last_name"),
("MiddleName", "middle_name"),
("Suffix", "suffix"),
("Salutation", "salutation"),
("Email", "email"),
# Skipping optional fields Username and Password
("Address1", "address_1"),
("Address2", "address_2"),
("Address3", "address_3"),
("City", "city"),
("State", "state"),
("PostalCode", "postal_code"),
("Country", "country"),
("Phone", "phone"),
("Extension", "extension"),
("PhoneCountryCode", "phone_country_code"),
("FAX", "fax"),
("FAXCountryCode", "fax_country_code"),
("CompanyName", "company_name"),
# Skipping optional field CustomQuestion
("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
])
# define defaults, even thought 'store_true' shouldn't need them.
# (call_command will set None as default value for all options that don't have one,
# so one cannot rely on presence/absence of flags in that world.)
option_list = BaseCommand.option_list + (
make_option('--dest-from-settings',
action='store_true',
dest='dest-from-settings',
default=False,
help='Retrieve the destination to export to from django.'),
make_option('--destination',
action='store',
dest='destination',
default=None,
help='Where to store the exported files')
)
def handle(self, **options):
# update time should use UTC in order to be comparable to the user_updated_at
# field
uploaded_at = datetime.now(UTC)
# if specified destination is an existing directory, then
# create a filename for it automatically. If it doesn't exist,
# then we will create the directory.
# Name will use timestamp -- this is UTC, so it will look funny,
# but it should at least be consistent with the other timestamps
# used in the system.
if 'dest-from-settings' in options and options['dest-from-settings']:
if 'LOCAL_EXPORT' in settings.PEARSON:
dest = settings.PEARSON['LOCAL_EXPORT']
else:
raise CommandError('--dest-from-settings was enabled but the'
'PEARSON[LOCAL_EXPORT] setting was not set.')
elif 'destination' in options and options['destination']:
dest = options['destination']
else:
raise CommandError('--destination or --dest-from-settings must be used')
if not os.path.isdir(dest):
os.makedirs(dest)
destfile = os.path.join(dest, uploaded_at.strftime("cdd-%Y%m%d-%H%M%S.dat"))
# strings must be in latin-1 format. CSV parser will
# otherwise convert unicode objects to ascii.
def ensure_encoding(value):
if isinstance(value, unicode):
return value.encode('iso-8859-1')
else:
return value
# dump_all = options['dump_all']
with open(destfile, "wb") as outfile:
writer = csv.DictWriter(outfile,
Command.CSV_TO_MODEL_FIELDS,
delimiter="\t",
quoting=csv.QUOTE_MINIMAL,
extrasaction='ignore')
writer.writeheader()
for tcu in TestCenterUser.objects.order_by('id'):
if tcu.needs_uploading: # or dump_all
record = dict((csv_field, ensure_encoding(getattr(tcu, model_field)))
for csv_field, model_field
in Command.CSV_TO_MODEL_FIELDS.items())
record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
writer.writerow(record)
tcu.uploaded_at = uploaded_at
tcu.save()
import csv
import os
from collections import OrderedDict
from datetime import datetime
from optparse import make_option
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from student.models import TestCenterRegistration, ACCOMMODATION_REJECTED_CODE
from pytz import UTC
class Command(BaseCommand):
CSV_TO_MODEL_FIELDS = OrderedDict([
('AuthorizationTransactionType', 'authorization_transaction_type'),
('AuthorizationID', 'authorization_id'),
('ClientAuthorizationID', 'client_authorization_id'),
('ClientCandidateID', 'client_candidate_id'),
('ExamAuthorizationCount', 'exam_authorization_count'),
('ExamSeriesCode', 'exam_series_code'),
('Accommodations', 'accommodation_code'),
('EligibilityApptDateFirst', 'eligibility_appointment_date_first'),
('EligibilityApptDateLast', 'eligibility_appointment_date_last'),
("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
])
option_list = BaseCommand.option_list + (
make_option('--dest-from-settings',
action='store_true',
dest='dest-from-settings',
default=False,
help='Retrieve the destination to export to from django.'),
make_option('--destination',
action='store',
dest='destination',
default=None,
help='Where to store the exported files'),
make_option('--dump_all',
action='store_true',
dest='dump_all',
default=False,
),
make_option('--force_add',
action='store_true',
dest='force_add',
default=False,
),
)
def handle(self, **options):
# update time should use UTC in order to be comparable to the user_updated_at
# field
uploaded_at = datetime.now(UTC)
# if specified destination is an existing directory, then
# create a filename for it automatically. If it doesn't exist,
# then we will create the directory.
# Name will use timestamp -- this is UTC, so it will look funny,
# but it should at least be consistent with the other timestamps
# used in the system.
if 'dest-from-settings' in options and options['dest-from-settings']:
if 'LOCAL_EXPORT' in settings.PEARSON:
dest = settings.PEARSON['LOCAL_EXPORT']
else:
raise CommandError('--dest-from-settings was enabled but the'
'PEARSON[LOCAL_EXPORT] setting was not set.')
elif 'destination' in options and options['destination']:
dest = options['destination']
else:
raise CommandError('--destination or --dest-from-settings must be used')
if not os.path.isdir(dest):
os.makedirs(dest)
destfile = os.path.join(dest, uploaded_at.strftime("ead-%Y%m%d-%H%M%S.dat"))
dump_all = options['dump_all']
with open(destfile, "wb") as outfile:
writer = csv.DictWriter(outfile,
Command.CSV_TO_MODEL_FIELDS,
delimiter="\t",
quoting=csv.QUOTE_MINIMAL,
extrasaction='ignore')
writer.writeheader()
for tcr in TestCenterRegistration.objects.order_by('id'):
if dump_all or tcr.needs_uploading:
record = dict((csv_field, getattr(tcr, model_field))
for csv_field, model_field
in Command.CSV_TO_MODEL_FIELDS.items())
record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
record["EligibilityApptDateFirst"] = record["EligibilityApptDateFirst"].strftime("%Y/%m/%d")
record["EligibilityApptDateLast"] = record["EligibilityApptDateLast"].strftime("%Y/%m/%d")
if record["Accommodations"] == ACCOMMODATION_REJECTED_CODE:
record["Accommodations"] = ""
if options['force_add']:
record['AuthorizationTransactionType'] = 'Add'
writer.writerow(record)
tcr.uploaded_at = uploaded_at
tcr.save()
import csv
from time import strptime, strftime
from datetime import datetime
from zipfile import ZipFile, is_zipfile
from dogapi import dog_http_api
from pytz import UTC
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
import django_startup
from student.models import TestCenterUser, TestCenterRegistration
django_startup.autostartup()
class Command(BaseCommand):
args = '<input zip file>'
help = """
Import Pearson confirmation files and update TestCenterUser
and TestCenterRegistration tables with status.
"""
@staticmethod
def datadog_error(string, tags):
dog_http_api.event("Pearson Import", string, alert_type='error', tags=[tags])
def handle(self, *args, **kwargs):
if len(args) < 1:
print Command.help
return
source_zip = args[0]
if not is_zipfile(source_zip):
error = "Input file is not a zipfile: \"{}\"".format(source_zip)
Command.datadog_error(error, source_zip)
raise CommandError(error)
# loop through all files in zip, and process them based on filename prefix:
with ZipFile(source_zip, 'r') as zipfile:
for fileinfo in zipfile.infolist():
with zipfile.open(fileinfo) as zipentry:
if fileinfo.filename.startswith("eac-"):
self.process_eac(zipentry)
elif fileinfo.filename.startswith("vcdc-"):
self.process_vcdc(zipentry)
else:
error = "Unrecognized confirmation file type\"{}\" in confirmation zip file \"{}\"".format(fileinfo.filename, zipfile)
Command.datadog_error(error, source_zip)
raise CommandError(error)
def process_eac(self, eacfile):
print "processing eac"
reader = csv.DictReader(eacfile, delimiter="\t")
for row in reader:
client_authorization_id = row['ClientAuthorizationID']
if not client_authorization_id:
if row['Status'] == 'Error':
Command.datadog_error("Error in EAD file processing ({}): {}".format(row['Date'], row['Message']), eacfile.name)
else:
Command.datadog_error("Encountered bad record: {}".format(row), eacfile.name)
else:
try:
registration = TestCenterRegistration.objects.get(client_authorization_id=client_authorization_id)
Command.datadog_error("Found authorization record for user {}".format(registration.testcenter_user.user.username), eacfile.name)
# now update the record:
registration.upload_status = row['Status']
registration.upload_error_message = row['Message']
try:
registration.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S'))
except ValueError as ve:
Command.datadog_error("Bad Date value found for {}: message {}".format(client_authorization_id, ve), eacfile.name)
# store the authorization Id if one is provided. (For debugging)
if row['AuthorizationID']:
try:
registration.authorization_id = int(row['AuthorizationID'])
except ValueError as ve:
Command.datadog_error("Bad AuthorizationID value found for {}: message {}".format(client_authorization_id, ve), eacfile.name)
registration.confirmed_at = datetime.now(UTC)
registration.save()
except TestCenterRegistration.DoesNotExist:
Command.datadog_error("Failed to find record for client_auth_id {}".format(client_authorization_id), eacfile.name)
def process_vcdc(self, vcdcfile):
print "processing vcdc"
reader = csv.DictReader(vcdcfile, delimiter="\t")
for row in reader:
client_candidate_id = row['ClientCandidateID']
if not client_candidate_id:
if row['Status'] == 'Error':
Command.datadog_error("Error in CDD file processing ({}): {}".format(row['Date'], row['Message']), vcdcfile.name)
else:
Command.datadog_error("Encountered bad record: {}".format(row), vcdcfile.name)
else:
try:
tcuser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id)
Command.datadog_error("Found demographics record for user {}".format(tcuser.user.username), vcdcfile.name)
# now update the record:
tcuser.upload_status = row['Status']
tcuser.upload_error_message = row['Message']
try:
tcuser.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S'))
except ValueError as ve:
Command.datadog_error("Bad Date value found for {}: message {}".format(client_candidate_id, ve), vcdcfile.name)
# store the candidate Id if one is provided. (For debugging)
if row['CandidateID']:
try:
tcuser.candidate_id = int(row['CandidateID'])
except ValueError as ve:
Command.datadog_error("Bad CandidateID value found for {}: message {}".format(client_candidate_id, ve), vcdcfile.name)
tcuser.confirmed_at = datetime.utcnow()
tcuser.save()
except TestCenterUser.DoesNotExist:
Command.datadog_error(" Failed to find record for client_candidate_id {}".format(client_candidate_id), vcdcfile.name)
from optparse import make_option
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError
from student.models import TestCenterUser, TestCenterRegistration, TestCenterRegistrationForm, get_testcenter_registration
from student.views import course_from_id
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError
class Command(BaseCommand):
option_list = BaseCommand.option_list + (
# registration info:
make_option(
'--accommodation_request',
action='store',
dest='accommodation_request',
),
make_option(
'--accommodation_code',
action='store',
dest='accommodation_code',
),
make_option(
'--client_authorization_id',
action='store',
dest='client_authorization_id',
),
# exam info:
make_option(
'--exam_series_code',
action='store',
dest='exam_series_code',
),
make_option(
'--eligibility_appointment_date_first',
action='store',
dest='eligibility_appointment_date_first',
help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.'
),
make_option(
'--eligibility_appointment_date_last',
action='store',
dest='eligibility_appointment_date_last',
help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.'
),
# internal values:
make_option(
'--authorization_id',
action='store',
dest='authorization_id',
help='ID we receive from Pearson for a particular authorization'
),
make_option(
'--upload_status',
action='store',
dest='upload_status',
help='status value assigned by Pearson'
),
make_option(
'--upload_error_message',
action='store',
dest='upload_error_message',
help='error message provided by Pearson on a failure.'
),
# control values:
make_option(
'--ignore_registration_dates',
action='store_true',
dest='ignore_registration_dates',
help='find exam info for course based on exam_series_code, even if the exam is not active.'
),
make_option(
'--create_dummy_exam',
action='store_true',
dest='create_dummy_exam',
help='create dummy exam info for course, even if course exists'
),
)
args = "<student_username course_id>"
help = "Create or modify a TestCenterRegistration entry for a given Student"
@staticmethod
def is_valid_option(option_name):
base_options = set(option.dest for option in BaseCommand.option_list)
return option_name not in base_options
def handle(self, *args, **options):
username = args[0]
course_id = args[1]
print username, course_id
our_options = dict((k, v) for k, v in options.items()
if Command.is_valid_option(k) and v is not None)
try:
student = User.objects.get(username=username)
except User.DoesNotExist:
raise CommandError("User \"{}\" does not exist".format(username))
try:
testcenter_user = TestCenterUser.objects.get(user=student)
except TestCenterUser.DoesNotExist:
raise CommandError("User \"{}\" does not have an existing demographics record".format(username))
# get an "exam" object. Check to see if a course_id was specified, and use information from that:
exam = None
create_dummy_exam = 'create_dummy_exam' in our_options and our_options['create_dummy_exam']
if not create_dummy_exam:
try:
course = course_from_id(course_id)
if 'ignore_registration_dates' in our_options:
examlist = [exam for exam in course.test_center_exams if exam.exam_series_code == our_options.get('exam_series_code')]
exam = examlist[0] if len(examlist) > 0 else None
else:
exam = course.current_test_center_exam
except ItemNotFoundError:
pass
else:
# otherwise use explicit values (so we don't have to define a course):
exam_name = "Dummy Placeholder Name"
exam_info = {'Exam_Series_Code': our_options['exam_series_code'],
'First_Eligible_Appointment_Date': our_options['eligibility_appointment_date_first'],
'Last_Eligible_Appointment_Date': our_options['eligibility_appointment_date_last'],
}
exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info)
# update option values for date_first and date_last to use YYYY-MM-DD format
# instead of YYYY-MM-DDTHH:MM
our_options['eligibility_appointment_date_first'] = exam.first_eligible_appointment_date.strftime("%Y-%m-%d")
our_options['eligibility_appointment_date_last'] = exam.last_eligible_appointment_date.strftime("%Y-%m-%d")
if exam is None:
raise CommandError("Exam for course_id {} does not exist".format(course_id))
exam_code = exam.exam_series_code
UPDATE_FIELDS = ('accommodation_request',
'accommodation_code',
'client_authorization_id',
'exam_series_code',
'eligibility_appointment_date_first',
'eligibility_appointment_date_last',
)
# create and save the registration:
needs_updating = False
registrations = get_testcenter_registration(student, course_id, exam_code)
if len(registrations) > 0:
registration = registrations[0]
for fieldname in UPDATE_FIELDS:
if fieldname in our_options and registration.__getattribute__(fieldname) != our_options[fieldname]:
needs_updating = True;
else:
accommodation_request = our_options.get('accommodation_request', '')
registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request)
needs_updating = True
if needs_updating:
# first update the record with the new values, if any:
for fieldname in UPDATE_FIELDS:
if fieldname in our_options and fieldname not in TestCenterRegistrationForm.Meta.fields:
registration.__setattr__(fieldname, our_options[fieldname])
# the registration form normally populates the data dict with
# the accommodation request (if any). But here we want to
# specify only those values that might change, so update the dict with existing
# values.
form_options = dict(our_options)
for propname in TestCenterRegistrationForm.Meta.fields:
if propname not in form_options:
form_options[propname] = registration.__getattribute__(propname)
form = TestCenterRegistrationForm(instance=registration, data=form_options)
if form.is_valid():
form.update_and_save()
print "Updated registration information for user's registration: username \"{}\" course \"{}\", examcode \"{}\"".format(student.username, course_id, exam_code)
else:
if (len(form.errors) > 0):
print "Field Form errors encountered:"
for fielderror in form.errors:
for msg in form.errors[fielderror]:
print "Field Form Error: {} -- {}".format(fielderror, msg)
if (len(form.non_field_errors()) > 0):
print "Non-field Form errors encountered:"
for nonfielderror in form.non_field_errors:
print "Non-field Form Error: %s" % nonfielderror
else:
print "No changes necessary to make to existing user's registration."
# override internal values:
change_internal = False
if 'exam_series_code' in our_options:
exam_code = our_options['exam_series_code']
registration = get_testcenter_registration(student, course_id, exam_code)[0]
for internal_field in ['upload_error_message', 'upload_status', 'authorization_id']:
if internal_field in our_options:
registration.__setattr__(internal_field, our_options[internal_field])
change_internal = True
if change_internal:
print "Updated confirmation information in existing user's registration."
registration.save()
else:
print "No changes necessary to make to confirmation information in existing user's registration."
from optparse import make_option
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError
from student.models import TestCenterUser, TestCenterUserForm
class Command(BaseCommand):
option_list = BaseCommand.option_list + (
# demographics:
make_option(
'--first_name',
action='store',
dest='first_name',
),
make_option(
'--middle_name',
action='store',
dest='middle_name',
),
make_option(
'--last_name',
action='store',
dest='last_name',
),
make_option(
'--suffix',
action='store',
dest='suffix',
),
make_option(
'--salutation',
action='store',
dest='salutation',
),
make_option(
'--address_1',
action='store',
dest='address_1',
),
make_option(
'--address_2',
action='store',
dest='address_2',
),
make_option(
'--address_3',
action='store',
dest='address_3',
),
make_option(
'--city',
action='store',
dest='city',
),
make_option(
'--state',
action='store',
dest='state',
help='Two letter code (e.g. MA)'
),
make_option(
'--postal_code',
action='store',
dest='postal_code',
),
make_option(
'--country',
action='store',
dest='country',
help='Three letter country code (ISO 3166-1 alpha-3), like USA'
),
make_option(
'--phone',
action='store',
dest='phone',
help='Pretty free-form (parens, spaces, dashes), but no country code'
),
make_option(
'--extension',
action='store',
dest='extension',
),
make_option(
'--phone_country_code',
action='store',
dest='phone_country_code',
help='Phone country code, just "1" for the USA'
),
make_option(
'--fax',
action='store',
dest='fax',
help='Pretty free-form (parens, spaces, dashes), but no country code'
),
make_option(
'--fax_country_code',
action='store',
dest='fax_country_code',
help='Fax country code, just "1" for the USA'
),
make_option(
'--company_name',
action='store',
dest='company_name',
),
# internal values:
make_option(
'--client_candidate_id',
action='store',
dest='client_candidate_id',
help='ID we assign a user to identify them to Pearson'
),
make_option(
'--upload_status',
action='store',
dest='upload_status',
help='status value assigned by Pearson'
),
make_option(
'--upload_error_message',
action='store',
dest='upload_error_message',
help='error message provided by Pearson on a failure.'
),
)
args = "<student_username>"
help = "Create or modify a TestCenterUser entry for a given Student"
@staticmethod
def is_valid_option(option_name):
base_options = set(option.dest for option in BaseCommand.option_list)
return option_name not in base_options
def handle(self, *args, **options):
username = args[0]
print username
our_options = dict((k, v) for k, v in options.items()
if Command.is_valid_option(k) and v is not None)
student = User.objects.get(username=username)
try:
testcenter_user = TestCenterUser.objects.get(user=student)
needs_updating = testcenter_user.needs_update(our_options)
except TestCenterUser.DoesNotExist:
# do additional initialization here:
testcenter_user = TestCenterUser.create(student)
needs_updating = True
if needs_updating:
# the registration form normally populates the data dict with
# all values from the testcenter_user. But here we only want to
# specify those values that change, so update the dict with existing
# values.
form_options = dict(our_options)
for propname in TestCenterUser.user_provided_fields():
if propname not in form_options:
form_options[propname] = testcenter_user.__getattribute__(propname)
form = TestCenterUserForm(instance=testcenter_user, data=form_options)
if form.is_valid():
form.update_and_save()
else:
errorlist = []
if (len(form.errors) > 0):
errorlist.append("Field Form errors encountered:")
for fielderror in form.errors:
errorlist.append("Field Form Error: {}".format(fielderror))
if (len(form.non_field_errors()) > 0):
errorlist.append("Non-field Form errors encountered:")
for nonfielderror in form.non_field_errors:
errorlist.append("Non-field Form Error: {}".format(nonfielderror))
raise CommandError("\n".join(errorlist))
else:
print "No changes necessary to make to existing user's demographics."
# override internal values:
change_internal = False
testcenter_user = TestCenterUser.objects.get(user=student)
for internal_field in ['upload_error_message', 'upload_status', 'client_candidate_id']:
if internal_field in our_options:
testcenter_user.__setattr__(internal_field, our_options[internal_field])
change_internal = True
if change_internal:
testcenter_user.save()
print "Updated confirmation information in existing user's demographics."
else:
print "No changes necessary to make to confirmation information in existing user's demographics."
from optparse import make_option
import os
from stat import S_ISDIR
import boto
from dogapi import dog_http_api, dog_stats_api
import paramiko
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
import django_startup
django_startup.autostartup()
class Command(BaseCommand):
help = """
This command handles the importing and exporting of student records for
Pearson. It uses some other Django commands to export and import the
files and then uploads over SFTP to Pearson and stuffs the entry in an
S3 bucket for archive purposes.
Usage: ./manage.py pearson-transfer --mode [import|export|both]
"""
option_list = BaseCommand.option_list + (
make_option('--mode',
action='store',
dest='mode',
default='both',
choices=('import', 'export', 'both'),
help='mode is import, export, or both'),
)
def handle(self, **options):
if not hasattr(settings, 'PEARSON'):
raise CommandError('No PEARSON entries in auth/env.json.')
# check settings needed for either import or export:
for value in ['SFTP_HOSTNAME', 'SFTP_USERNAME', 'SFTP_PASSWORD', 'S3_BUCKET']:
if value not in settings.PEARSON:
raise CommandError('No entry in the PEARSON settings'
'(env/auth.json) for {0}'.format(value))
for value in ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY']:
if not hasattr(settings, value):
raise CommandError('No entry in the AWS settings'
'(env/auth.json) for {0}'.format(value))
# check additional required settings for import and export:
if options['mode'] in ('export', 'both'):
for value in ['LOCAL_EXPORT', 'SFTP_EXPORT']:
if value not in settings.PEARSON:
raise CommandError('No entry in the PEARSON settings'
'(env/auth.json) for {0}'.format(value))
# make sure that the import directory exists or can be created:
source_dir = settings.PEARSON['LOCAL_EXPORT']
if not os.path.isdir(source_dir):
os.makedirs(source_dir)
if options['mode'] in ('import', 'both'):
for value in ['LOCAL_IMPORT', 'SFTP_IMPORT']:
if value not in settings.PEARSON:
raise CommandError('No entry in the PEARSON settings'
'(env/auth.json) for {0}'.format(value))
# make sure that the import directory exists or can be created:
dest_dir = settings.PEARSON['LOCAL_IMPORT']
if not os.path.isdir(dest_dir):
os.makedirs(dest_dir)
def sftp(files_from, files_to, mode, deleteAfterCopy=False):
with dog_stats_api.timer('pearson.{0}'.format(mode), tags='sftp'):
try:
t = paramiko.Transport((settings.PEARSON['SFTP_HOSTNAME'], 22))
t.connect(username=settings.PEARSON['SFTP_USERNAME'],
password=settings.PEARSON['SFTP_PASSWORD'])
sftp = paramiko.SFTPClient.from_transport(t)
if mode == 'export':
try:
sftp.chdir(files_to)
except IOError:
raise CommandError('SFTP destination path does not exist: {}'.format(files_to))
for filename in os.listdir(files_from):
sftp.put(files_from + '/' + filename, filename)
if deleteAfterCopy:
os.remove(os.path.join(files_from, filename))
else:
try:
sftp.chdir(files_from)
except IOError:
raise CommandError('SFTP source path does not exist: {}'.format(files_from))
for filename in sftp.listdir('.'):
# skip subdirectories
if not S_ISDIR(sftp.stat(filename).st_mode):
sftp.get(filename, files_to + '/' + filename)
# delete files from sftp server once they are successfully pulled off:
if deleteAfterCopy:
sftp.remove(filename)
except:
dog_http_api.event('pearson {0}'.format(mode),
'sftp uploading failed',
alert_type='error')
raise
finally:
sftp.close()
t.close()
def s3(files_from, bucket, mode, deleteAfterCopy=False):
with dog_stats_api.timer('pearson.{0}'.format(mode), tags='s3'):
try:
for filename in os.listdir(files_from):
source_file = os.path.join(files_from, filename)
# use mode as name of directory into which to write files
dest_file = os.path.join(mode, filename)
upload_file_to_s3(bucket, source_file, dest_file)
if deleteAfterCopy:
os.remove(files_from + '/' + filename)
except:
dog_http_api.event('pearson {0}'.format(mode),
's3 archiving failed')
raise
def upload_file_to_s3(bucket, source_file, dest_file):
"""
Upload file to S3
"""
s3 = boto.connect_s3(settings.AWS_ACCESS_KEY_ID,
settings.AWS_SECRET_ACCESS_KEY)
from boto.s3.key import Key
b = s3.get_bucket(bucket)
k = Key(b)
k.key = "{filename}".format(filename=dest_file)
k.set_contents_from_filename(source_file)
def export_pearson():
options = {'dest-from-settings': True}
call_command('pearson_export_cdd', **options)
call_command('pearson_export_ead', **options)
mode = 'export'
sftp(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['SFTP_EXPORT'], mode, deleteAfterCopy=False)
s3(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=True)
def import_pearson():
mode = 'import'
try:
sftp(settings.PEARSON['SFTP_IMPORT'], settings.PEARSON['LOCAL_IMPORT'], mode, deleteAfterCopy=True)
s3(settings.PEARSON['LOCAL_IMPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=False)
except Exception as e:
dog_http_api.event('Pearson Import failure', str(e))
raise e
else:
for filename in os.listdir(settings.PEARSON['LOCAL_IMPORT']):
filepath = os.path.join(settings.PEARSON['LOCAL_IMPORT'], filename)
call_command('pearson_import_conf_zip', filepath)
os.remove(filepath)
# actually do the work!
if options['mode'] in ('export', 'both'):
export_pearson()
if options['mode'] in ('import', 'both'):
import_pearson()
"""
Generate sql commands to fix truncated anonymous student ids in the ORA database
"""
import sys
from django.core.management.base import NoArgsCommand
from student.models import AnonymousUserId, anonymous_id_for_user
class Command(NoArgsCommand):
help = __doc__
def handle_noargs(self, **options):
"""
Reads a list of ids (newline separated) from stdin, and
dumps sql queries to run on the ORA database to fix those ids
from their truncated form to the full 32 character change.
The following query will generate the list of ids needed to be fixed
from the ORA database:
SELECT student_id FROM peer_grading_calibrationhistory WHERE LENGTH(student_id) = 16
UNION SELECT student_id FROM controller_submission WHERE LENGTH(student_id) = 16
UNION SELECT student_id FROM metrics_timing WHERE LENGTH(student_id) = 16
UNION SELECT student_id FROM metrics_studentcourseprofile WHERE LENGTH(student_id) = 16
UNION SELECT student_id FROM metrics_studentprofile WHERE LENGTH(student_id) = 16;
"""
ids = [line.strip() for line in sys.stdin]
old_ids = AnonymousUserId.objects.raw(
"""
SELECT *
FROM student_anonymoususerid_temp_archive
WHERE anonymous_user_id IN ({})
""".format(','.join(['%s']*len(ids))),
ids
)
for old_id in old_ids:
new_id = anonymous_id_for_user(old_id.user, old_id.course_id)
for table in ('peer_grading_calibrationhistory', 'controller_submission', 'metrics_timing'):
self.stdout.write(
"UPDATE {} "
"SET student_id = '{}' "
"WHERE student_id = '{}';\n".format(
table,
new_id,
old_id.anonymous_user_id,
)
)
self.stdout.write(
"DELETE FROM metrics_studentcourseprofile "
"WHERE student_id = '{}' "
"AND problems_attempted = 0;\n".format(old_id.anonymous_user_id)
)
self.stdout.write(
"DELETE FROM metrics_studentprofile "
"WHERE student_id = '{}' "
"AND messages_sent = 0 "
"AND messages_received = 0 "
"AND average_message_feedback_length = 0 "
"AND student_is_staff_banned = 0 "
"AND student_cannot_submit_more_for_peer_grading = 0;\n".format(old_id.anonymous_user_id)
)
...@@ -59,23 +59,28 @@ class ResetPasswordTests(TestCase): ...@@ -59,23 +59,28 @@ class ResetPasswordTests(TestCase):
self.user_bad_passwd.password = UNUSABLE_PASSWORD self.user_bad_passwd.password = UNUSABLE_PASSWORD
self.user_bad_passwd.save() self.user_bad_passwd.save()
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_user_bad_password_reset(self): def test_user_bad_password_reset(self):
"""Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD"""
bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email})
bad_pwd_resp = password_reset(bad_pwd_req) bad_pwd_resp = password_reset(bad_pwd_req)
# If they've got an unusable password, we return a successful response code
self.assertEquals(bad_pwd_resp.status_code, 200) self.assertEquals(bad_pwd_resp.status_code, 200)
self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False, self.assertEquals(bad_pwd_resp.content, json.dumps({'success': True,
'error': 'Invalid e-mail or user'})) 'value': "('registration/password_reset_done.html', [])"}))
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_nonexist_email_password_reset(self): def test_nonexist_email_password_reset(self):
"""Now test the exception cases with of reset_password called with invalid email.""" """Now test the exception cases with of reset_password called with invalid email."""
bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"})
bad_email_resp = password_reset(bad_email_req) bad_email_resp = password_reset(bad_email_req)
# Note: even if the email is bad, we return a successful response code
# This prevents someone potentially trying to "brute-force" find out which emails are and aren't registered with edX
self.assertEquals(bad_email_resp.status_code, 200) self.assertEquals(bad_email_resp.status_code, 200)
self.assertEquals(bad_email_resp.content, json.dumps({'success': False, self.assertEquals(bad_email_resp.content, json.dumps({'success': True,
'error': 'Invalid e-mail or user'})) 'value': "('registration/password_reset_done.html', [])"}))
@unittest.skipUnless(not settings.MITX_FEATURES.get('DISABLE_PASSWORD_RESET_EMAIL_TEST', False), @unittest.skipUnless(not settings.MITX_FEATURES.get('DISABLE_PASSWORD_RESET_EMAIL_TEST', False),
dedent("""Skipping Test because CMS has not provided necessary templates for password reset. dedent("""Skipping Test because CMS has not provided necessary templates for password reset.
...@@ -152,38 +157,43 @@ class CourseEndingTest(TestCase): ...@@ -152,38 +157,43 @@ class CourseEndingTest(TestCase):
{'status': 'processing', {'status': 'processing',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False, }) 'show_survey_button': False,
})
cert_status = {'status': 'unavailable'} cert_status = {'status': 'unavailable'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'processing', {'status': 'processing',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False}) 'show_survey_button': False,
'mode': None
})
cert_status = {'status': 'generating', 'grade': '67'} cert_status = {'status': 'generating', 'grade': '67', 'mode': 'honor'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating', {'status': 'generating',
'show_disabled_download_button': True, 'show_disabled_download_button': True,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': True, 'show_survey_button': True,
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67' 'grade': '67',
'mode': 'honor'
}) })
cert_status = {'status': 'regenerating', 'grade': '67'} cert_status = {'status': 'regenerating', 'grade': '67', 'mode': 'verified'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating', {'status': 'generating',
'show_disabled_download_button': True, 'show_disabled_download_button': True,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': True, 'show_survey_button': True,
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67' 'grade': '67',
'mode': 'verified'
}) })
download_url = 'http://s3.edx/cert' download_url = 'http://s3.edx/cert'
cert_status = {'status': 'downloadable', 'grade': '67', cert_status = {'status': 'downloadable', 'grade': '67',
'download_url': download_url} 'download_url': download_url, 'mode': 'honor'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'ready', {'status': 'ready',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
...@@ -191,30 +201,33 @@ class CourseEndingTest(TestCase): ...@@ -191,30 +201,33 @@ class CourseEndingTest(TestCase):
'download_url': download_url, 'download_url': download_url,
'show_survey_button': True, 'show_survey_button': True,
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67' 'grade': '67',
'mode': 'honor'
}) })
cert_status = {'status': 'notpassing', 'grade': '67', cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url} 'download_url': download_url, 'mode': 'honor'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'notpassing', {'status': 'notpassing',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': True, 'show_survey_button': True,
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67' 'grade': '67',
'mode': 'honor'
}) })
# Test a course that doesn't have a survey specified # Test a course that doesn't have a survey specified
course2 = Mock(end_of_course_survey_url=None) course2 = Mock(end_of_course_survey_url=None)
cert_status = {'status': 'notpassing', 'grade': '67', cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url} 'download_url': download_url, 'mode': 'honor'}
self.assertEqual(_cert_info(user, course2, cert_status), self.assertEqual(_cert_info(user, course2, cert_status),
{'status': 'notpassing', {'status': 'notpassing',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False, 'show_survey_button': False,
'grade': '67' 'grade': '67',
'mode': 'honor'
}) })
...@@ -329,6 +342,14 @@ class EnrollInCourseTest(TestCase): ...@@ -329,6 +342,14 @@ class EnrollInCourseTest(TestCase):
) )
self.assertFalse(enrollment_record.is_active) self.assertFalse(enrollment_record.is_active)
# Make sure mode is updated properly if user unenrolls & re-enrolls
enrollment = CourseEnrollment.enroll(user, course_id, "verified")
self.assertEquals(enrollment.mode, "verified")
CourseEnrollment.unenroll(user, course_id)
enrollment = CourseEnrollment.enroll(user, course_id, "audit")
self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
self.assertEquals(enrollment.mode, "audit")
def assert_no_events_were_emitted(self): def assert_no_events_were_emitted(self):
"""Ensures no events were emitted since the last event related assertion""" """Ensures no events were emitted since the last event related assertion"""
self.assertFalse(self.mock_server_track.called) self.assertFalse(self.mock_server_track.called)
......
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