5
.gitignore
vendored
5
.gitignore
vendored
@@ -17,6 +17,7 @@ cms/envs/private.py
|
||||
/nbproject
|
||||
.idea/
|
||||
.redcar/
|
||||
codekit-config.json
|
||||
|
||||
### OS X artifacts
|
||||
*.DS_Store
|
||||
@@ -48,14 +49,18 @@ reports/
|
||||
.prereqs_cache
|
||||
.vagrant/
|
||||
node_modules
|
||||
.bundle/
|
||||
bin/
|
||||
|
||||
### Static assets pipeline artifacts
|
||||
*.scssc
|
||||
lms/static/css/
|
||||
lms/static/sass/*.css
|
||||
lms/static/sass/application.scss
|
||||
lms/static/sass/application-extend1.scss
|
||||
lms/static/sass/application-extend2.scss
|
||||
lms/static/sass/course.scss
|
||||
cms/static/css/
|
||||
cms/static/sass/*.css
|
||||
|
||||
### Logging artifacts
|
||||
|
||||
2
AUTHORS
2
AUTHORS
@@ -97,3 +97,5 @@ Iain Dunning <idunning@mit.edu>
|
||||
Olivier Marquez <oliviermarquez@gmail.com>
|
||||
Florian Dufour <neurolit@gmail.com>
|
||||
Manuel Freire <manuel.freire@fdi.ucm.es>
|
||||
Daniel Cebrián Robles <danielcebrianr@gmail.com>
|
||||
Carson Gee <cgee@mit.edu>
|
||||
|
||||
@@ -5,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
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
Blades: Fix Numerical input to support mathematical operations. BLD-525.
|
||||
|
||||
Blades: Improve calculator's tooltip accessibility. Add possibility to navigate
|
||||
through the hints via arrow keys. BLD-533.
|
||||
|
||||
LMS: Add feature for providing background grade report generation via Celery
|
||||
instructor task, with reports uploaded to S3. Feature is visible on the beta
|
||||
instructor dashboard. LMS-58
|
||||
@@ -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
|
||||
implemented. BLD-384.
|
||||
|
||||
LMS: Beta-tester status is now set on a per-course-run basis, rather than being valid
|
||||
across all runs with the same course name. Old group membership will still work
|
||||
across runs, but new beta-testers will only be added to a single course run.
|
||||
LMS: Beta-tester status is now set on a per-course-run basis, rather than being
|
||||
valid across all runs with the same course name. Old group membership will
|
||||
still work across runs, but new beta-testers will only be added to a single
|
||||
course run.
|
||||
|
||||
Blades: Enabled several Video Jasmine tests. BLD-463.
|
||||
|
||||
Studio: Continued modification of Studio pages to follow a RESTful framework.
|
||||
includes Settings pages, edit page for Subsection and Unit, and interfaces
|
||||
for updating xblocks (xmodules) and getting their editing HTML.
|
||||
|
||||
LMS: Improve accessibility of inline discussions in courseware.
|
||||
|
||||
Blades: Put 2nd "Hide output" button at top of test box & increase text size for
|
||||
code response questions. BLD-126.
|
||||
|
||||
Blades: Update the calculator hints tooltip with full information. BLD-400.
|
||||
|
||||
Blades: Fix transcripts 500 error in studio (BLD-530)
|
||||
|
||||
LMS: Add error recovery when a user loads or switches pages in an
|
||||
inline discussion.
|
||||
|
||||
Blades: Allow multiple strings as the correct answer to a string response
|
||||
question. BLD-474.
|
||||
|
||||
Blades: a11y - Videos will alert screenreaders when the video is over.
|
||||
|
||||
LMS: Trap focus on the loading element when a user loads more threads
|
||||
in the forum sidebar to improve accessibility.
|
||||
|
||||
LMS: Add error recovery when a user loads more threads in the forum sidebar.
|
||||
|
||||
LMS: Add a user-visible alert modal when a forums AJAX request fails.
|
||||
|
||||
@@ -36,7 +70,8 @@ text like with bold or italics. (BLD-449)
|
||||
LMS: Beta instructor dashboard will only count actively enrolled students for
|
||||
course enrollment numbers.
|
||||
|
||||
Blades: Fix speed menu that is not rendered correctly when YouTube is unavailable. (BLD-457).
|
||||
Blades: Fix speed menu that is not rendered correctly when YouTube is
|
||||
unavailable. (BLD-457).
|
||||
|
||||
LMS: Users with is_staff=True no longer have the STAFF label appear on
|
||||
their forum posts.
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
duplicate tasks in celery.
|
||||
|
||||
@@ -70,8 +108,8 @@ client error are correctly passed through to the client.
|
||||
LMS: Improve performance of page load and thread list load for
|
||||
discussion tab
|
||||
|
||||
LMS: The wiki markup cheatsheet dialog is now accessible to people with
|
||||
disabilites. (LMS-1303)
|
||||
LMS: The wiki markup cheatsheet dialog is now accessible to screen readers.
|
||||
(LMS-1303)
|
||||
|
||||
Common: Add skip links for accessibility to CMS and LMS. (LMS-1311)
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
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
|
||||
@@ -11,7 +14,8 @@ from django.conf import settings
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.locator import CourseLocator, Locator
|
||||
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
|
||||
@@ -26,7 +30,11 @@ COURSE_CREATOR_GROUP_NAME = "course_creator_group"
|
||||
# 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)
|
||||
|
||||
# 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):
|
||||
except InvalidLocationError: # will occur on old locations where location is not of category course
|
||||
pass
|
||||
if isinstance(location, Location):
|
||||
# least preferred role_course format
|
||||
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):
|
||||
old_location = loc_mapper().translate_locator_to_location(location, get_course=True)
|
||||
if old_location:
|
||||
# the slashified version of the course_id (myu/mycourse/myrun)
|
||||
groupnames.append('{0}_{1}'.format(role, old_location.course_id))
|
||||
|
||||
for groupname in groupnames:
|
||||
if Group.objects.filter(name=groupname).exists():
|
||||
return groupname
|
||||
return groupnames[0]
|
||||
# add the least desirable but sometimes occurring format.
|
||||
groupnames.append('{0}_{1}'.format(role, old_location.course))
|
||||
# filter to the ones which exist
|
||||
default = 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):
|
||||
groupname = get_course_groupname_for_role(location, role)
|
||||
(group, _created) = Group.objects.get_or_create(name=groupname)
|
||||
return group.user_set.all()
|
||||
def get_course_groupname_for_role(location, role):
|
||||
'''
|
||||
Get the preferred used groupname for this role, location combo.
|
||||
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):
|
||||
@@ -65,11 +97,11 @@ def create_all_course_groups(creator, location):
|
||||
|
||||
|
||||
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)
|
||||
if created:
|
||||
group.save()
|
||||
|
||||
'''
|
||||
Create the new course group always using the preferred name even if another form already exists.
|
||||
'''
|
||||
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.save()
|
||||
|
||||
@@ -82,15 +114,13 @@ def _delete_course_group(location):
|
||||
asserted permissions
|
||||
"""
|
||||
# remove all memberships
|
||||
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
|
||||
for user in instructors.user_set.all():
|
||||
user.groups.remove(instructors)
|
||||
user.save()
|
||||
|
||||
staff = Group.objects.get(name=get_course_groupname_for_role(location, STAFF_ROLE_NAME))
|
||||
for user in staff.user_set.all():
|
||||
user.groups.remove(staff)
|
||||
user.save()
|
||||
for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
|
||||
groupnames, _ = get_all_course_role_groupnames(location, role)
|
||||
for groupname in groupnames:
|
||||
group = Group.objects.get(name=groupname)
|
||||
for user in group.user_set.all():
|
||||
user.groups.remove(group)
|
||||
user.save()
|
||||
|
||||
|
||||
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
|
||||
asserted permissions to do this action
|
||||
"""
|
||||
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME))
|
||||
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
|
||||
for user in instructors.user_set.all():
|
||||
user.groups.add(new_instructors_group)
|
||||
user.save()
|
||||
|
||||
staff = Group.objects.get(name=get_course_groupname_for_role(source, STAFF_ROLE_NAME))
|
||||
new_staff_group = Group.objects.get(name=get_course_groupname_for_role(dest, STAFF_ROLE_NAME))
|
||||
for user in staff.user_set.all():
|
||||
user.groups.add(new_staff_group)
|
||||
user.save()
|
||||
for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]:
|
||||
groupnames, _ = get_all_course_role_groupnames(source, role)
|
||||
for groupname in groupnames:
|
||||
group = Group.objects.get(name=groupname)
|
||||
new_group, _ = Group.objects.get_or_create(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
|
||||
for user in group.user_set.all():
|
||||
user.groups.add(new_group)
|
||||
user.save()
|
||||
|
||||
|
||||
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
|
||||
if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME):
|
||||
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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
raise PermissionDenied
|
||||
|
||||
(group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME)
|
||||
if created:
|
||||
group.save()
|
||||
(group, _) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME)
|
||||
return _add_user_to_group(user, group)
|
||||
|
||||
|
||||
@@ -152,6 +180,9 @@ def _add_user_to_group(user, group):
|
||||
|
||||
|
||||
def get_user_by_email(email):
|
||||
"""
|
||||
Get the user whose email is the arg. Return None if no such user exists.
|
||||
"""
|
||||
user = None
|
||||
# try to look up user, return None if not found
|
||||
try:
|
||||
@@ -163,13 +194,21 @@ def get_user_by_email(email):
|
||||
|
||||
|
||||
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
|
||||
if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied
|
||||
|
||||
# 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):
|
||||
_remove_user_from_group(user, get_course_groupname_for_role(location, role))
|
||||
groupnames, _ = get_all_course_role_groupnames(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):
|
||||
@@ -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):
|
||||
"""
|
||||
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:
|
||||
# all "is_staff" flagged accounts belong to all groups
|
||||
if check_staff and user.is_staff:
|
||||
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
|
||||
|
||||
|
||||
@@ -6,14 +6,6 @@ from nose.tools import assert_equal, assert_in # pylint: disable=E0611
|
||||
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
|
||||
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
|
||||
world.wait_for_invisible(component_button_css)
|
||||
click_component_from_menu(category, component_type, is_advanced)
|
||||
|
||||
world.wait_for(lambda _: _is_expected_element_count(module_css,
|
||||
module_count_before + 1))
|
||||
expected_count = module_count_before + 1
|
||||
world.wait_for(
|
||||
lambda _: len(world.css_find(module_css)) == expected_count,
|
||||
timeout=20
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
|
||||
@@ -76,3 +76,17 @@ Feature: CMS.Course updates
|
||||
Then I see the handout "/c4x/MITx/999/asset/modified.jpg"
|
||||
And when I reload the page
|
||||
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):
|
||||
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):
|
||||
type_in_codemirror(0, text)
|
||||
save_css = 'a.save-button'
|
||||
|
||||
@@ -9,10 +9,8 @@ Feature: CMS.Static Pages
|
||||
Then I should see a static page named "Empty"
|
||||
|
||||
Scenario: Users can delete static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
And I add a new page
|
||||
And I "delete" the static page
|
||||
Given I have created a static page
|
||||
When I "delete" the static page
|
||||
Then I am shown a prompt
|
||||
When I confirm the prompt
|
||||
Then I should not see any static pages
|
||||
@@ -20,9 +18,16 @@ Feature: CMS.Static Pages
|
||||
# Safari won't update the name properly
|
||||
@skip_safari
|
||||
Scenario: Users can edit static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
And I add a new page
|
||||
Given I have created a static page
|
||||
When I "edit" the static page
|
||||
And I change the name to "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):
|
||||
world.trigger_event(input_css)
|
||||
save_button = 'a.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
|
||||
|
||||
And I save changes
|
||||
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 open tab "Advanced"
|
||||
|
||||
@@ -116,6 +116,7 @@ def i_see_status_message(_step, status):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
assert not world.css_visible(SELECTORS['error_bar'])
|
||||
assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()])
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ Feature: CMS.Video Component
|
||||
Then Captions become "invisible"
|
||||
|
||||
# 8
|
||||
# Disabled 11/26 due to flakiness in master.
|
||||
# Enabled back on 11/29.
|
||||
Scenario: Open captions never become invisible
|
||||
Given I have created a Video component with subtitles
|
||||
And Make sure captions are open
|
||||
@@ -63,6 +65,8 @@ Feature: CMS.Video Component
|
||||
Then Captions are "visible"
|
||||
|
||||
# 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
|
||||
Given I have created a Video component with subtitles
|
||||
And Make sure captions are closed
|
||||
@@ -71,6 +75,8 @@ Feature: CMS.Video Component
|
||||
Then Captions are "invisible"
|
||||
|
||||
# 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
|
||||
Given I have created a Video component with subtitles
|
||||
And Make sure captions are opened
|
||||
|
||||
@@ -181,7 +181,7 @@ def click_on_the_caption(_step, index):
|
||||
@step('I see caption line with data-index "([^"]*)" has class "([^"]*)"$')
|
||||
def caption_line_has_class(_step, index, className):
|
||||
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$')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#pylint: disable=E1101
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import mock
|
||||
|
||||
@@ -15,6 +14,7 @@ from fs.osfs import OSFS
|
||||
import copy
|
||||
from json import loads
|
||||
from datetime import timedelta
|
||||
from django.test import TestCase
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.dispatch import Signal
|
||||
@@ -42,6 +42,7 @@ from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_TYPES
|
||||
from xmodule.exceptions import NotFoundError
|
||||
@@ -53,6 +54,7 @@ from pytz import UTC
|
||||
from uuid import uuid4
|
||||
from pymongo import MongoClient
|
||||
from student.models import CourseEnrollment
|
||||
import re
|
||||
|
||||
from contentstore.utils import delete_course_and_groups
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
@@ -132,9 +134,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
# just pick one vertical
|
||||
descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0]
|
||||
|
||||
resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
locator = loc_mapper().translate_location(course.location.course_id, descriptor.location, False, True)
|
||||
resp = self.client.get_html(locator.url_reverse('unit'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
for expected in expected_types:
|
||||
self.assertIn(expected, resp.content)
|
||||
@@ -152,25 +155,24 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
def test_malformed_edit_unit_request(self):
|
||||
store = modulestore('direct')
|
||||
import_from_xml(store, 'common/test/data/', ['simple'])
|
||||
_, course_items = import_from_xml(store, 'common/test/data/', ['simple'])
|
||||
|
||||
# just pick one vertical
|
||||
descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0]
|
||||
location = descriptor.location.replace(name='.' + descriptor.location.name)
|
||||
locator = loc_mapper().translate_location(course_items[0].location.course_id, location, False, True)
|
||||
|
||||
resp = self.client.get_html(reverse('edit_unit', kwargs={'location': location.url()}))
|
||||
resp = self.client.get_html(locator.url_reverse('unit'))
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
_test_no_locations(self, resp, status_code=400)
|
||||
|
||||
def check_edit_unit(self, test_course_name):
|
||||
import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name])
|
||||
_, course_items = import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name])
|
||||
|
||||
for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)):
|
||||
print "Checking ", descriptor.location.url()
|
||||
print descriptor.__class__, descriptor.location
|
||||
resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
items = modulestore().get_items(Location('i4x', 'edX', test_course_name, 'vertical', None, None))
|
||||
self._check_verticals(items, course_items[0].location.course_id)
|
||||
|
||||
def lockAnAsset(self, content_store, course_location):
|
||||
def _lock_an_asset(self, content_store, course_location):
|
||||
"""
|
||||
Lock an arbitrary asset in the course
|
||||
:param course_location:
|
||||
@@ -398,15 +400,63 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(course.tabs, expected_tabs)
|
||||
|
||||
def test_static_tab_reordering(self):
|
||||
def get_tab_locator(tab):
|
||||
tab_location = 'i4x://MITx/999/static_tab/{0}'.format(tab['url_slug'])
|
||||
return unicode(loc_mapper().translate_location(
|
||||
course.location.course_id, Location(tab_location), False, True
|
||||
))
|
||||
module_store, course_location, new_location = self._create_static_tabs()
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
# reverse the ordering
|
||||
reverse_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reverse_tabs.insert(0, unicode(self._get_tab_locator(course, tab)))
|
||||
|
||||
self.client.ajax_post(new_location.url_reverse('tabs'), {'tabs': reverse_tabs})
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
# compare to make sure that the tabs information is in the expected order after the server call
|
||||
course_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
course_tabs.append(unicode(self._get_tab_locator(course, tab)))
|
||||
|
||||
self.assertEqual(reverse_tabs, course_tabs)
|
||||
|
||||
def test_static_tab_deletion(self):
|
||||
module_store, course_location, _ = self._create_static_tabs()
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
num_tabs = len(course.tabs)
|
||||
last_tab = course.tabs[num_tabs - 1]
|
||||
url_slug = last_tab['url_slug']
|
||||
delete_url = self._get_tab_locator(course, last_tab).url_reverse('xblock')
|
||||
|
||||
self.client.delete(delete_url)
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
self.assertEqual(num_tabs - 1, len(course.tabs))
|
||||
|
||||
def tab_matches(tab):
|
||||
""" Checks if the tab matches the one we deleted """
|
||||
return tab['type'] == 'static_tab' and tab['url_slug'] == url_slug
|
||||
|
||||
tab_found = any(tab_matches(tab) for tab in course.tabs)
|
||||
|
||||
self.assertFalse(tab_found, "tab should have been deleted")
|
||||
|
||||
def _get_tab_locator(self, course, tab):
|
||||
""" Returns the locator for a given tab. """
|
||||
tab_location = 'i4x://MITx/999/static_tab/{0}'.format(tab['url_slug'])
|
||||
return loc_mapper().translate_location(
|
||||
course.location.course_id, Location(tab_location), False, True
|
||||
)
|
||||
|
||||
def _create_static_tabs(self):
|
||||
""" Creates two static tabs in a dummy course. """
|
||||
module_store = modulestore('direct')
|
||||
locator = _course_factory_create_course()
|
||||
course_location = loc_mapper().translate_locator_to_location(locator)
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
new_location = loc_mapper().translate_location(course_location.course_id, course_location, False, True)
|
||||
|
||||
ItemFactory.create(
|
||||
parent_location=course_location,
|
||||
@@ -417,25 +467,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
category="static_tab",
|
||||
display_name="Static_2")
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
# reverse the ordering
|
||||
reverse_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reverse_tabs.insert(0, get_tab_locator(tab))
|
||||
|
||||
self.client.ajax_post(reverse('reorder_static_tabs'), {'tabs': reverse_tabs})
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
# compare to make sure that the tabs information is in the expected order after the server call
|
||||
course_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
course_tabs.append(get_tab_locator(tab))
|
||||
|
||||
self.assertEqual(reverse_tabs, course_tabs)
|
||||
return module_store, course_location, new_location
|
||||
|
||||
def test_import_polls(self):
|
||||
module_store = modulestore('direct')
|
||||
@@ -454,31 +486,38 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/toy/.*'])
|
||||
def test_module_preview_in_whitelist(self):
|
||||
'''
|
||||
"""
|
||||
Tests the ajax callback to render an XModule
|
||||
'''
|
||||
direct_store = modulestore('direct')
|
||||
import_from_xml(direct_store, 'common/test/data/', ['toy'])
|
||||
|
||||
# also try a custom response which will trigger the 'is this course in whitelist' logic
|
||||
problem_module_location = Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None])
|
||||
url = reverse('preview_component', kwargs={'location': problem_module_location.url()})
|
||||
resp = self.client.get_html(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
"""
|
||||
resp = self._test_preview(Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]))
|
||||
# These are the data-ids of the xblocks contained in the vertical.
|
||||
# Ultimately, these must be converted to new locators.
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/sample_video')
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/separate_file_video')
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time')
|
||||
self.assertContains(resp, 'i4x://edX/toy/poll_question/T1_changemind_poll_foo_2')
|
||||
|
||||
def test_video_module_caption_asset_path(self):
|
||||
'''
|
||||
"""
|
||||
This verifies that a video caption url is as we expect it to be
|
||||
'''
|
||||
"""
|
||||
resp = self._test_preview(Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None]))
|
||||
self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"')
|
||||
|
||||
def _test_preview(self, location):
|
||||
""" Preview test case. """
|
||||
direct_store = modulestore('direct')
|
||||
import_from_xml(direct_store, 'common/test/data/', ['toy'])
|
||||
_, course_items = import_from_xml(direct_store, 'common/test/data/', ['toy'])
|
||||
|
||||
# also try a custom response which will trigger the 'is this course in whitelist' logic
|
||||
video_module_location = Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None])
|
||||
url = reverse('preview_component', kwargs={'location': video_module_location.url()})
|
||||
resp = self.client.get_html(url)
|
||||
locator = loc_mapper().translate_location(
|
||||
course_items[0].location.course_id, location, False, True
|
||||
)
|
||||
resp = self.client.get_html(locator.url_reverse('xblock'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"')
|
||||
# TODO: uncomment when preview no longer has locations being returned.
|
||||
# _test_no_locations(self, resp)
|
||||
return resp
|
||||
|
||||
def test_delete(self):
|
||||
direct_store = modulestore('direct')
|
||||
@@ -617,7 +656,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
def _delete_asset_in_course (self):
|
||||
def _delete_asset_in_course(self):
|
||||
"""
|
||||
Helper method for:
|
||||
1) importing course from xml
|
||||
@@ -836,6 +875,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
def test_bad_contentstore_request(self):
|
||||
resp = self.client.get_html('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
_test_no_locations(self, resp, 400)
|
||||
|
||||
def test_rewrite_nonportable_links_on_import(self):
|
||||
module_store = modulestore('direct')
|
||||
@@ -955,7 +995,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertIn(private_location_no_draft.url(), sequential.children)
|
||||
|
||||
locked_asset = self.lockAnAsset(content_store, location)
|
||||
locked_asset = self._lock_an_asset(content_store, location)
|
||||
locked_asset_attrs = content_store.get_attrs(locked_asset)
|
||||
# the later import will reupload
|
||||
del locked_asset_attrs['uploadDate']
|
||||
@@ -1010,7 +1050,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def check_import(self, module_store, root_dir, draft_store, content_store, stub_location, course_location,
|
||||
locked_asset, locked_asset_attrs):
|
||||
locked_asset, locked_asset_attrs):
|
||||
# reimport
|
||||
import_from_xml(
|
||||
module_store, root_dir, ['test_export'], draft_store=draft_store,
|
||||
@@ -1018,15 +1058,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
target_location_namespace=course_location
|
||||
)
|
||||
|
||||
# Unit test fails in Jenkins without this.
|
||||
loc_mapper().translate_location(course_location.course_id, course_location, False, True)
|
||||
|
||||
items = module_store.get_items(stub_location.replace(category='vertical', name=None))
|
||||
self.assertGreater(len(items), 0)
|
||||
for descriptor in items:
|
||||
# don't try to look at private verticals. Right now we're running
|
||||
# the service in non-draft aware
|
||||
if getattr(descriptor, 'is_draft', False):
|
||||
print "Checking {0}....".format(descriptor.location.url())
|
||||
resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self._check_verticals(items, course_location.course_id)
|
||||
|
||||
# verify that we have the content in the draft store as well
|
||||
vertical = draft_store.get_item(
|
||||
@@ -1210,7 +1246,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
handouts_locator = loc_mapper().translate_location('edX/toy/2012_Fall', handout_location)
|
||||
|
||||
# get module info (json)
|
||||
resp = self.client.get(handouts_locator.url_reverse('/xblock', ''))
|
||||
resp = self.client.get(handouts_locator.url_reverse('/xblock'))
|
||||
|
||||
# make sure we got a successful response
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
@@ -1309,6 +1345,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
items = module_store.get_items(stub_location)
|
||||
self.assertEqual(len(items), 1)
|
||||
|
||||
def _check_verticals(self, items, course_id):
|
||||
""" Test getting the editing HTML for each vertical. """
|
||||
# Assert is here to make sure that the course being tested actually has verticals (units) to check.
|
||||
self.assertGreater(len(items), 0)
|
||||
for descriptor in items:
|
||||
unit_locator = loc_mapper().translate_location(course_id, descriptor.location, False, True)
|
||||
resp = self.client.get_html(unit_locator.url_reverse('unit'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
|
||||
class ContentStoreTest(ModuleStoreTestCase):
|
||||
@@ -1387,7 +1433,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
second_course_data = self.assert_created_course(number_suffix=uuid4().hex)
|
||||
|
||||
# unseed the forums for the first course
|
||||
course_id =_get_course_id(test_course_data)
|
||||
course_id = _get_course_id(test_course_data)
|
||||
delete_course_and_groups(course_id, commit=True)
|
||||
self.assertFalse(are_permissions_roles_seeded(course_id))
|
||||
|
||||
@@ -1503,6 +1549,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
status_code=200,
|
||||
html=True
|
||||
)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
def test_course_factory(self):
|
||||
"""Test that the course factory works correctly."""
|
||||
@@ -1525,6 +1572,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
status_code=200,
|
||||
html=True
|
||||
)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
def test_course_overview_view_with_course(self):
|
||||
"""Test viewing the course overview page with an existing course"""
|
||||
@@ -1550,12 +1598,13 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
}
|
||||
|
||||
resp = self.client.ajax_post('/xblock', section_data)
|
||||
_test_no_locations(self, resp, html=False)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertRegexpMatches(
|
||||
data['id'],
|
||||
r"^i4x://MITx/999/chapter/([0-9]|[a-f]){32}$"
|
||||
data['locator'],
|
||||
r"^MITx.999.Robot_Super_Course/branch/draft/block/chapter([0-9]|[a-f]){3}$"
|
||||
)
|
||||
|
||||
def test_capa_module(self):
|
||||
@@ -1571,7 +1620,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = parse_json(resp)
|
||||
problem_loc = Location(payload['id'])
|
||||
problem_loc = loc_mapper().translate_locator_to_location(BlockUsageLocator(payload['locator']))
|
||||
problem = get_modulestore(problem_loc).get_item(problem_loc)
|
||||
# should be a CapaDescriptor
|
||||
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
|
||||
@@ -1584,6 +1633,13 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
Import and walk through some common URL endpoints. This just verifies non-500 and no other
|
||||
correct behavior, so it is not a deep test
|
||||
"""
|
||||
def test_get_html(page):
|
||||
# Helper function for getting HTML for a page in Studio and
|
||||
# checking that it does not error.
|
||||
resp = self.client.get_html(new_location.url_reverse(page))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
import_from_xml(modulestore('direct'), 'common/test/data/', ['simple'])
|
||||
loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None])
|
||||
new_location = loc_mapper().translate_location(loc.course_id, loc, False, True)
|
||||
@@ -1593,55 +1649,38 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
self.assertContains(resp, 'Chapter 2')
|
||||
|
||||
# go to various pages
|
||||
test_get_html('import')
|
||||
test_get_html('export')
|
||||
test_get_html('course_team')
|
||||
test_get_html('course_info')
|
||||
test_get_html('checklists')
|
||||
test_get_html('assets')
|
||||
test_get_html('tabs')
|
||||
test_get_html('settings/details')
|
||||
test_get_html('settings/grading')
|
||||
test_get_html('settings/advanced')
|
||||
|
||||
# import page
|
||||
resp = self.client.get_html(new_location.url_reverse('import/', ''))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# export page
|
||||
resp = self.client.get_html(new_location.url_reverse('export/', ''))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# course team
|
||||
url = new_location.url_reverse('course_team/', '')
|
||||
resp = self.client.get_html(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# course info
|
||||
resp = self.client.get(new_location.url_reverse('course_info'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# settings_details
|
||||
resp = self.client.get(reverse('settings_details',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# settings_details
|
||||
resp = self.client.get(reverse('settings_grading',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# assets_handler (HTML for full page content)
|
||||
url = new_location.url_reverse('assets/', '')
|
||||
resp = self.client.get_html(url)
|
||||
# textbook index
|
||||
resp = self.client.get_html(reverse('textbook_index',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
# go look at a subsection page
|
||||
subsection_location = loc.replace(category='sequential', name='test_sequence')
|
||||
resp = self.client.get_html(
|
||||
reverse('edit_subsection', kwargs={'location': subsection_location.url()})
|
||||
)
|
||||
subsection_locator = loc_mapper().translate_location(loc.course_id, subsection_location, False, True)
|
||||
resp = self.client.get_html(subsection_locator.url_reverse('subsection'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
# go look at the Edit page
|
||||
unit_location = loc.replace(category='vertical', name='test_vertical')
|
||||
resp = self.client.get_html(
|
||||
reverse('edit_unit', kwargs={'location': unit_location.url()}))
|
||||
unit_locator = loc_mapper().translate_location(loc.course_id, unit_location, False, True)
|
||||
resp = self.client.get_html(unit_locator.url_reverse('unit'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
def delete_item(category, name):
|
||||
""" Helper method for testing the deletion of an xblock item. """
|
||||
@@ -1649,6 +1688,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
del_location = loc_mapper().translate_location(loc.course_id, del_loc, False, True)
|
||||
resp = self.client.delete(del_location.url_reverse('xblock'))
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
_test_no_locations(self, resp, status_code=204, html=False)
|
||||
|
||||
# delete a component
|
||||
delete_item(category='html', name='test_html')
|
||||
@@ -1848,7 +1888,9 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
Show the course overview page.
|
||||
"""
|
||||
new_location = loc_mapper().translate_location(location.course_id, location, False, True)
|
||||
return self.client.get_html(new_location.url_reverse('course/', ''))
|
||||
resp = self.client.get_html(new_location.url_reverse('course/', ''))
|
||||
_test_no_locations(self, resp)
|
||||
return resp
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_MODULESTORE)
|
||||
@@ -1915,6 +1957,32 @@ class MetadataSaveTestCase(ModuleStoreTestCase):
|
||||
pass
|
||||
|
||||
|
||||
class EntryPageTestCase(TestCase):
|
||||
"""
|
||||
Tests entry pages that aren't specific to a course.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.client = AjaxEnabledTestClient()
|
||||
|
||||
def _test_page(self, page, status_code=200):
|
||||
resp = self.client.get_html(page)
|
||||
self.assertEqual(resp.status_code, status_code)
|
||||
_test_no_locations(self, resp, status_code)
|
||||
|
||||
def test_how_it_works(self):
|
||||
self._test_page("/howitworks")
|
||||
|
||||
def test_signup(self):
|
||||
self._test_page("/signup")
|
||||
|
||||
def test_login(self):
|
||||
self._test_page("/signin")
|
||||
|
||||
def test_logout(self):
|
||||
# Logout redirects.
|
||||
self._test_page("/logout", 302)
|
||||
|
||||
|
||||
def _create_course(test, course_data):
|
||||
"""
|
||||
Creates a course via an AJAX request and verifies the URL returned in the response.
|
||||
@@ -1926,7 +1994,7 @@ def _create_course(test, course_data):
|
||||
test.assertEqual(response.status_code, 200)
|
||||
data = parse_json(response)
|
||||
test.assertNotIn('ErrMsg', data)
|
||||
test.assertEqual(data['url'], new_location.url_reverse("course/", ""))
|
||||
test.assertEqual(data['url'], new_location.url_reverse("course"))
|
||||
|
||||
|
||||
def _course_factory_create_course():
|
||||
@@ -1940,3 +2008,19 @@ def _course_factory_create_course():
|
||||
def _get_course_id(test_course_data):
|
||||
"""Returns the course ID (org/number/run)."""
|
||||
return "{org}/{number}/{run}".format(**test_course_data)
|
||||
|
||||
|
||||
def _test_no_locations(test, resp, status_code=200, html=True):
|
||||
"""
|
||||
Verifies that "i4x", which appears in old locations, but not
|
||||
new locators, does not appear in the HTML response output.
|
||||
Used to verify that database refactoring is complete.
|
||||
"""
|
||||
test.assertNotContains(resp, 'i4x', status_code=status_code, html=html)
|
||||
if html:
|
||||
# For HTML pages, it is nice to call the method with html=True because
|
||||
# it checks that the HTML properly parses. However, it won't find i4x usages
|
||||
# in JavaScript blocks.
|
||||
content = resp.content
|
||||
hits = len(re.findall(r"(?<!jump_to/)i4x://", content))
|
||||
test.assertEqual(hits, 0, "i4x found outside of LMS jump-to links")
|
||||
|
||||
@@ -3,7 +3,6 @@ Unit tests for getting the list of courses and the course outline.
|
||||
"""
|
||||
import json
|
||||
import lxml
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
@@ -60,8 +59,7 @@ class TestCourseIndex(CourseTestCase):
|
||||
"""
|
||||
Test the error conditions for the access
|
||||
"""
|
||||
locator = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
|
||||
outline_url = locator.url_reverse('course/', '')
|
||||
outline_url = self.course_locator.url_reverse('course/', '')
|
||||
# register a non-staff member and try to delete the course branch
|
||||
non_staff_client, _ = self.createNonStaffAuthedUserClient()
|
||||
response = non_staff_client.delete(outline_url, {}, HTTP_ACCEPT='application/json')
|
||||
|
||||
@@ -6,14 +6,12 @@ import json
|
||||
import copy
|
||||
import mock
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.timezone import UTC
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from contentstore.utils import get_modulestore
|
||||
from contentstore.utils import get_modulestore, EXTRA_TAB_PANELS
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
@@ -21,6 +19,8 @@ from models.settings.course_metadata import CourseMetadata
|
||||
from xmodule.fields import Date
|
||||
|
||||
from .utils import CourseTestCase
|
||||
from xmodule.modulestore.django import loc_mapper, modulestore
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY
|
||||
|
||||
|
||||
class CourseDetailsTestCase(CourseTestCase):
|
||||
@@ -28,8 +28,10 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
Tests the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def test_virgin_fetch(self):
|
||||
details = CourseDetails.fetch(self.course.location)
|
||||
self.assertEqual(details.course_location, self.course.location, "Location not copied into")
|
||||
details = CourseDetails.fetch(self.course_locator)
|
||||
self.assertEqual(details.org, self.course.location.org, "Org not copied into")
|
||||
self.assertEqual(details.course_id, self.course.location.course, "Course_id not copied into")
|
||||
self.assertEqual(details.run, self.course.location.name, "Course name not copied into")
|
||||
self.assertEqual(details.course_image_name, self.course.course_image)
|
||||
self.assertIsNotNone(details.start_date.tzinfo)
|
||||
self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date))
|
||||
@@ -40,10 +42,9 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
|
||||
|
||||
def test_encoder(self):
|
||||
details = CourseDetails.fetch(self.course.location)
|
||||
details = CourseDetails.fetch(self.course_locator)
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
self.assertTupleEqual(Location(jsondetails['course_location']), self.course.location, "Location !=")
|
||||
self.assertEqual(jsondetails['course_image_name'], self.course.course_image)
|
||||
self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ")
|
||||
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
|
||||
@@ -57,7 +58,6 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
Test the encoder out of its original constrained purpose to see if it functions for general use
|
||||
"""
|
||||
details = {
|
||||
'location': Location(['tag', 'org', 'course', 'category', 'name']),
|
||||
'number': 1,
|
||||
'string': 'string',
|
||||
'datetime': datetime.datetime.now(UTC())
|
||||
@@ -65,59 +65,49 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
|
||||
self.assertIn('location', jsondetails)
|
||||
self.assertIn('org', jsondetails['location'])
|
||||
self.assertEquals('org', jsondetails['location'][1])
|
||||
self.assertEquals(1, jsondetails['number'])
|
||||
self.assertEqual(jsondetails['string'], 'string')
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
jsondetails = CourseDetails.fetch(self.course.location)
|
||||
jsondetails = CourseDetails.fetch(self.course_locator)
|
||||
jsondetails.syllabus = "<a href='foo'>bar</a>"
|
||||
# encode - decode to convert date fields and other data which changes form
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).syllabus,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).syllabus,
|
||||
jsondetails.syllabus, "After set syllabus"
|
||||
)
|
||||
jsondetails.overview = "Overview"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).overview,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).overview,
|
||||
jsondetails.overview, "After set overview"
|
||||
)
|
||||
jsondetails.intro_video = "intro_video"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).intro_video,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).intro_video,
|
||||
jsondetails.intro_video, "After set intro_video"
|
||||
)
|
||||
jsondetails.effort = "effort"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).effort,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).effort,
|
||||
jsondetails.effort, "After set effort"
|
||||
)
|
||||
jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC())
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).start_date,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).start_date,
|
||||
jsondetails.start_date
|
||||
)
|
||||
jsondetails.course_image_name = "an_image.jpg"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).course_image_name,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).course_image_name,
|
||||
jsondetails.course_image_name
|
||||
)
|
||||
|
||||
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
|
||||
def test_marketing_site_fetch(self):
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course.location.org,
|
||||
'name': self.course.location.name,
|
||||
'course': self.course.location.course
|
||||
}
|
||||
)
|
||||
settings_details_url = self.course_locator.url_reverse('settings/details/')
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
|
||||
response = self.client.get(settings_details_url)
|
||||
response = self.client.get_html(settings_details_url)
|
||||
self.assertNotContains(response, "Course Summary Page")
|
||||
self.assertNotContains(response, "Send a note to students via email")
|
||||
self.assertContains(response, "course summary page will not be viewable")
|
||||
@@ -135,17 +125,10 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertNotContains(response, "Requirements")
|
||||
|
||||
def test_regular_site_fetch(self):
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course.location.org,
|
||||
'name': self.course.location.name,
|
||||
'course': self.course.location.course
|
||||
}
|
||||
)
|
||||
settings_details_url = self.course_locator.url_reverse('settings/details/')
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
|
||||
response = self.client.get(settings_details_url)
|
||||
response = self.client.get_html(settings_details_url)
|
||||
self.assertContains(response, "Course Summary Page")
|
||||
self.assertContains(response, "Send a note to students via email")
|
||||
self.assertNotContains(response, "course summary page will not be viewable")
|
||||
@@ -168,10 +151,12 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
Tests for modifying content on the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def alter_field(self, url, details, field, val):
|
||||
"""
|
||||
Change the one field to the given value and then invoke the update post to see if it worked.
|
||||
"""
|
||||
setattr(details, field, val)
|
||||
# Need to partially serialize payload b/c the mock doesn't handle it correctly
|
||||
payload = copy.copy(details.__dict__)
|
||||
payload['course_location'] = details.course_location.url()
|
||||
payload['start_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.start_date)
|
||||
payload['end_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.end_date)
|
||||
payload['enrollment_start'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_start)
|
||||
@@ -181,16 +166,17 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
|
||||
@staticmethod
|
||||
def convert_datetime_to_iso(datetime_obj):
|
||||
"""
|
||||
Use the xblock serializer to convert the datetime
|
||||
"""
|
||||
return Date().to_json(datetime_obj)
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
loc = self.course.location
|
||||
details = CourseDetails.fetch(loc)
|
||||
details = CourseDetails.fetch(self.course_locator)
|
||||
|
||||
# resp s/b json from here on
|
||||
url = reverse('course_settings', kwargs={'org': loc.org, 'course': loc.course,
|
||||
'name': loc.name, 'section': 'details'})
|
||||
resp = self.client.get(url)
|
||||
url = self.course_locator.url_reverse('settings/details/')
|
||||
resp = self.client.get_json(url)
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get")
|
||||
|
||||
utc = UTC()
|
||||
@@ -206,6 +192,9 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.alter_field(url, details, 'course_image_name', "course_image_name")
|
||||
|
||||
def compare_details_with_encoding(self, encoded, details, context):
|
||||
"""
|
||||
compare all of the fields of the before and after dicts
|
||||
"""
|
||||
self.compare_date_fields(details, encoded, context, 'start_date')
|
||||
self.compare_date_fields(details, encoded, context, 'end_date')
|
||||
self.compare_date_fields(details, encoded, context, 'enrollment_start')
|
||||
@@ -216,6 +205,9 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==")
|
||||
|
||||
def compare_date_fields(self, details, encoded, context, field):
|
||||
"""
|
||||
Compare the given date fields between the before and after doing json deserialization
|
||||
"""
|
||||
if details[field] is not None:
|
||||
date = Date()
|
||||
if field in encoded and encoded[field] is not None:
|
||||
@@ -234,142 +226,191 @@ class CourseGradingTest(CourseTestCase):
|
||||
Tests for the course settings grading page.
|
||||
"""
|
||||
def test_initial_grader(self):
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
test_grader = CourseGradingModel(descriptor)
|
||||
# ??? How much should this test bake in expectations about defaults and thus fail if defaults change?
|
||||
self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
|
||||
self.assertIsNotNone(test_grader.graders, "No graders")
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
|
||||
test_grader = CourseGradingModel(self.course)
|
||||
self.assertIsNotNone(test_grader.graders)
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs)
|
||||
|
||||
def test_fetch_grader(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location.url())
|
||||
self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
|
||||
self.assertIsNotNone(test_grader.graders, "No graders")
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
|
||||
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertIsNotNone(test_grader.graders, "No graders")
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
|
||||
|
||||
for i, grader in enumerate(test_grader.graders):
|
||||
subgrader = CourseGradingModel.fetch_grader(self.course.location, i)
|
||||
subgrader = CourseGradingModel.fetch_grader(self.course_locator, i)
|
||||
self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal")
|
||||
|
||||
subgrader = CourseGradingModel.fetch_grader(self.course.location.list(), 0)
|
||||
self.assertDictEqual(test_grader.graders[0], subgrader, "failed with location as list")
|
||||
|
||||
def test_fetch_cutoffs(self):
|
||||
test_grader = CourseGradingModel.fetch_cutoffs(self.course.location)
|
||||
# ??? should this check that it's at least a dict? (expected is { "pass" : 0.5 } I think)
|
||||
self.assertIsNotNone(test_grader, "No cutoffs via fetch")
|
||||
|
||||
test_grader = CourseGradingModel.fetch_cutoffs(self.course.location.url())
|
||||
self.assertIsNotNone(test_grader, "No cutoffs via fetch with url")
|
||||
|
||||
def test_fetch_grace(self):
|
||||
test_grader = CourseGradingModel.fetch_grace_period(self.course.location)
|
||||
# almost a worthless test
|
||||
self.assertIn('grace_period', test_grader, "No grace via fetch")
|
||||
|
||||
test_grader = CourseGradingModel.fetch_grace_period(self.course.location.url())
|
||||
self.assertIn('grace_period', test_grader, "No cutoffs via fetch with url")
|
||||
|
||||
def test_update_from_json(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__)
|
||||
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update")
|
||||
|
||||
test_grader.graders[0]['weight'] = test_grader.graders[0].get('weight') * 2
|
||||
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
|
||||
altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__)
|
||||
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2")
|
||||
|
||||
test_grader.grade_cutoffs['D'] = 0.3
|
||||
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
|
||||
altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__)
|
||||
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D")
|
||||
|
||||
test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0}
|
||||
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
|
||||
altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__)
|
||||
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period")
|
||||
|
||||
def test_update_grader_from_json(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(self.course_locator, test_grader.graders[1])
|
||||
self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update")
|
||||
|
||||
test_grader.graders[1]['min_count'] = test_grader.graders[1].get('min_count') + 2
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(self.course_locator, test_grader.graders[1])
|
||||
self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2")
|
||||
|
||||
test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(self.course_locator, test_grader.graders[1])
|
||||
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
|
||||
|
||||
def test_update_cutoffs_from_json(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
CourseGradingModel.update_cutoffs_from_json(self.course_locator, test_grader.grade_cutoffs)
|
||||
# Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json
|
||||
# simply returns the cutoffs you send into it, rather than returning the db contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update")
|
||||
|
||||
test_grader.grade_cutoffs['D'] = 0.3
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_cutoffs_from_json(self.course_locator, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D")
|
||||
|
||||
test_grader.grade_cutoffs['Pass'] = 0.75
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_cutoffs_from_json(self.course_locator, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'")
|
||||
|
||||
def test_delete_grace_period(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
CourseGradingModel.update_grace_period_from_json(self.course_locator, test_grader.grace_period)
|
||||
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update")
|
||||
|
||||
test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30}
|
||||
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_grace_period_from_json(self.course_locator, test_grader.grace_period)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period")
|
||||
|
||||
test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0}
|
||||
# Now delete the grace period
|
||||
CourseGradingModel.delete_grace_period(test_grader.course_location)
|
||||
CourseGradingModel.delete_grace_period(self.course_locator)
|
||||
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
# Once deleted, the grace period should simply be None
|
||||
self.assertEqual(None, altered_grader.grace_period, "Delete grace period")
|
||||
|
||||
def test_update_section_grader_type(self):
|
||||
# Get the descriptor and the section_grader_type and assert they are the default values
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course_locator)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.format)
|
||||
self.assertEqual(False, descriptor.graded)
|
||||
|
||||
# Change the default grader type to Homework, which should also mark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'})
|
||||
CourseGradingModel.update_section_grader_type(self.course, 'Homework')
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course_locator)
|
||||
|
||||
self.assertEqual('Homework', section_grader_type['graderType'])
|
||||
self.assertEqual('Homework', descriptor.format)
|
||||
self.assertEqual(True, descriptor.graded)
|
||||
|
||||
# Change the grader type back to Not Graded, which should also unmark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'})
|
||||
CourseGradingModel.update_section_grader_type(self.course, 'Not Graded')
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course_locator)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.format)
|
||||
self.assertEqual(False, descriptor.graded)
|
||||
|
||||
def test_get_set_grader_types_ajax(self):
|
||||
"""
|
||||
Test configuring the graders via ajax calls
|
||||
"""
|
||||
grader_type_url_base = self.course_locator.url_reverse('settings/grading')
|
||||
# test get whole
|
||||
response = self.client.get_json(grader_type_url_base)
|
||||
whole_model = json.loads(response.content)
|
||||
self.assertIn('graders', whole_model)
|
||||
self.assertIn('grade_cutoffs', whole_model)
|
||||
self.assertIn('grace_period', whole_model)
|
||||
# test post/update whole
|
||||
whole_model['grace_period'] = {'hours': 1, 'minutes': 30, 'seconds': 0}
|
||||
response = self.client.ajax_post(grader_type_url_base, whole_model)
|
||||
self.assertEqual(200, response.status_code)
|
||||
response = self.client.get_json(grader_type_url_base)
|
||||
whole_model = json.loads(response.content)
|
||||
self.assertEqual(whole_model['grace_period'], {'hours': 1, 'minutes': 30, 'seconds': 0})
|
||||
# test get one grader
|
||||
self.assertGreater(len(whole_model['graders']), 1) # ensure test will make sense
|
||||
response = self.client.get_json(grader_type_url_base + '/1')
|
||||
grader_sample = json.loads(response.content)
|
||||
self.assertEqual(grader_sample, whole_model['graders'][1])
|
||||
# test add grader
|
||||
new_grader = {
|
||||
"type": "Extra Credit",
|
||||
"min_count": 1,
|
||||
"drop_count": 2,
|
||||
"short_label": None,
|
||||
"weight": 15,
|
||||
}
|
||||
response = self.client.ajax_post(
|
||||
'{}/{}'.format(grader_type_url_base, len(whole_model['graders'])),
|
||||
new_grader
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
grader_sample = json.loads(response.content)
|
||||
new_grader['id'] = len(whole_model['graders'])
|
||||
self.assertEqual(new_grader, grader_sample)
|
||||
# test delete grader
|
||||
response = self.client.delete(grader_type_url_base + '/1', HTTP_ACCEPT="application/json")
|
||||
self.assertEqual(204, response.status_code)
|
||||
response = self.client.get_json(grader_type_url_base)
|
||||
updated_model = json.loads(response.content)
|
||||
new_grader['id'] -= 1 # one fewer and the id mutates
|
||||
self.assertIn(new_grader, updated_model['graders'])
|
||||
self.assertNotIn(whole_model['graders'][1], updated_model['graders'])
|
||||
|
||||
def setup_test_set_get_section_grader_ajax(self):
|
||||
"""
|
||||
Populate the course, grab a section, get the url for the assignment type access
|
||||
"""
|
||||
self.populateCourse()
|
||||
sections = get_modulestore(self.course_location).get_items(
|
||||
self.course_location.replace(category="sequential", name=None)
|
||||
)
|
||||
# see if test makes sense
|
||||
self.assertGreater(len(sections), 0, "No sections found")
|
||||
section = sections[0] # just take the first one
|
||||
section_locator = loc_mapper().translate_location(self.course_location.course_id, section.location, False, True)
|
||||
return section_locator.url_reverse('xblock')
|
||||
|
||||
def test_set_get_section_grader_ajax(self):
|
||||
"""
|
||||
Test setting and getting section grades via the grade as url
|
||||
"""
|
||||
grade_type_url = self.setup_test_set_get_section_grader_ajax()
|
||||
response = self.client.ajax_post(grade_type_url, {'graderType': u'Homework'})
|
||||
self.assertEqual(200, response.status_code)
|
||||
response = self.client.get_json(grade_type_url + '?fields=graderType')
|
||||
self.assertEqual(json.loads(response.content).get('graderType'), u'Homework')
|
||||
# and unset
|
||||
response = self.client.ajax_post(grade_type_url, {'graderType': u'Not Graded'})
|
||||
self.assertEqual(200, response.status_code)
|
||||
response = self.client.get_json(grade_type_url + '?fields=graderType')
|
||||
self.assertEqual(json.loads(response.content).get('graderType'), u'Not Graded')
|
||||
|
||||
|
||||
class CourseMetadataEditingTest(CourseTestCase):
|
||||
"""
|
||||
@@ -377,15 +418,19 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
"""
|
||||
def setUp(self):
|
||||
CourseTestCase.setUp(self)
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
self.fullcourse_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
self.fullcourse = CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
self.course_setting_url = self.course_locator.url_reverse('settings/advanced')
|
||||
self.fullcourse_setting_url = loc_mapper().translate_location(
|
||||
self.fullcourse.location.course_id,
|
||||
self.fullcourse.location, False, True
|
||||
).url_reverse('settings/advanced')
|
||||
|
||||
def test_fetch_initial_fields(self):
|
||||
test_model = CourseMetadata.fetch(self.course.location)
|
||||
test_model = CourseMetadata.fetch(self.course)
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
|
||||
test_model = CourseMetadata.fetch(self.fullcourse_location)
|
||||
test_model = CourseMetadata.fetch(self.fullcourse)
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
@@ -394,17 +439,17 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertIn('xqa_key', test_model, 'xqa_key field ')
|
||||
|
||||
def test_update_from_json(self):
|
||||
test_model = CourseMetadata.update_from_json(self.course.location, {
|
||||
test_model = CourseMetadata.update_from_json(self.course, {
|
||||
"advertised_start": "start A",
|
||||
"testcenter_info": {"c": "test"},
|
||||
"days_early_for_beta": 2
|
||||
})
|
||||
self.update_check(test_model)
|
||||
# try fresh fetch to ensure persistence
|
||||
test_model = CourseMetadata.fetch(self.course.location)
|
||||
fresh = modulestore().get_item(self.course_location)
|
||||
test_model = CourseMetadata.fetch(fresh)
|
||||
self.update_check(test_model)
|
||||
# now change some of the existing metadata
|
||||
test_model = CourseMetadata.update_from_json(self.course.location, {
|
||||
test_model = CourseMetadata.update_from_json(fresh, {
|
||||
"advertised_start": "start B",
|
||||
"display_name": "jolly roger"}
|
||||
)
|
||||
@@ -418,13 +463,15 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field')
|
||||
self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value")
|
||||
self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field')
|
||||
self.assertDictEqual(test_model['testcenter_info'], {"c": "test"}, "testcenter_info not expected value")
|
||||
self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field')
|
||||
self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value")
|
||||
|
||||
def test_delete_key(self):
|
||||
test_model = CourseMetadata.delete_key(self.fullcourse_location, {'deleteKeys': ['doesnt_exist', 'showanswer', 'xqa_key']})
|
||||
test_model = CourseMetadata.update_from_json(
|
||||
self.fullcourse, {
|
||||
"unsetKeys": ['showanswer', 'xqa_key']
|
||||
}
|
||||
)
|
||||
# ensure no harm
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
@@ -434,27 +481,113 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in')
|
||||
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')
|
||||
|
||||
def test_http_fetch_initial_fields(self):
|
||||
response = self.client.get_json(self.course_setting_url)
|
||||
test_model = json.loads(response.content)
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
|
||||
response = self.client.get_json(self.fullcourse_setting_url)
|
||||
test_model = json.loads(response.content)
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
|
||||
self.assertIn('showanswer', test_model, 'showanswer field ')
|
||||
self.assertIn('xqa_key', test_model, 'xqa_key field ')
|
||||
|
||||
def test_http_update_from_json(self):
|
||||
response = self.client.ajax_post(self.course_setting_url, {
|
||||
"advertised_start": "start A",
|
||||
"testcenter_info": {"c": "test"},
|
||||
"days_early_for_beta": 2,
|
||||
"unsetKeys": ['showanswer', 'xqa_key'],
|
||||
})
|
||||
test_model = json.loads(response.content)
|
||||
self.update_check(test_model)
|
||||
self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in')
|
||||
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')
|
||||
|
||||
response = self.client.get_json(self.course_setting_url)
|
||||
test_model = json.loads(response.content)
|
||||
self.update_check(test_model)
|
||||
# now change some of the existing metadata
|
||||
response = self.client.ajax_post(self.course_setting_url, {
|
||||
"advertised_start": "start B",
|
||||
"display_name": "jolly roger"
|
||||
})
|
||||
test_model = json.loads(response.content)
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value")
|
||||
self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
|
||||
self.assertEqual(test_model['advertised_start'], 'start B', "advertised_start not expected value")
|
||||
|
||||
def test_advanced_components_munge_tabs(self):
|
||||
"""
|
||||
Test that adding and removing specific advanced components adds and removes tabs.
|
||||
"""
|
||||
self.assertNotIn(EXTRA_TAB_PANELS.get("open_ended"), self.course.tabs)
|
||||
self.assertNotIn(EXTRA_TAB_PANELS.get("notes"), self.course.tabs)
|
||||
self.client.ajax_post(self.course_setting_url, {
|
||||
ADVANCED_COMPONENT_POLICY_KEY: ["combinedopenended"]
|
||||
})
|
||||
course = modulestore().get_item(self.course_location)
|
||||
self.assertIn(EXTRA_TAB_PANELS.get("open_ended"), course.tabs)
|
||||
self.assertNotIn(EXTRA_TAB_PANELS.get("notes"), course.tabs)
|
||||
self.client.ajax_post(self.course_setting_url, {
|
||||
ADVANCED_COMPONENT_POLICY_KEY: []
|
||||
})
|
||||
course = modulestore().get_item(self.course_location)
|
||||
self.assertNotIn(EXTRA_TAB_PANELS.get("open_ended"), course.tabs)
|
||||
|
||||
|
||||
class CourseGraderUpdatesTest(CourseTestCase):
|
||||
"""
|
||||
Test getting, deleting, adding, & updating graders
|
||||
"""
|
||||
def setUp(self):
|
||||
"""Compute the url to use in tests"""
|
||||
super(CourseGraderUpdatesTest, self).setUp()
|
||||
self.url = reverse("course_settings", kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'grader_index': 0,
|
||||
})
|
||||
self.url = self.course_locator.url_reverse('settings/grading')
|
||||
self.starting_graders = CourseGradingModel(self.course).graders
|
||||
|
||||
def test_get(self):
|
||||
resp = self.client.get(self.url)
|
||||
"""Test getting a specific grading type record."""
|
||||
resp = self.client.get_json(self.url + '/0')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
obj = json.loads(resp.content)
|
||||
self.assertEqual(self.starting_graders[0], obj)
|
||||
|
||||
def test_delete(self):
|
||||
resp = self.client.delete(self.url)
|
||||
"""Test deleting a specific grading type record."""
|
||||
resp = self.client.delete(self.url + '/0', HTTP_ACCEPT="application/json")
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
current_graders = CourseGradingModel.fetch(self.course_locator).graders
|
||||
self.assertNotIn(self.starting_graders[0], current_graders)
|
||||
self.assertEqual(len(self.starting_graders) - 1, len(current_graders))
|
||||
|
||||
def test_post(self):
|
||||
def test_update(self):
|
||||
"""Test updating a specific grading type record."""
|
||||
grader = {
|
||||
"id": 0,
|
||||
"type": "manual",
|
||||
"min_count": 5,
|
||||
"drop_count": 10,
|
||||
"short_label": "yo momma",
|
||||
"weight": 17.3,
|
||||
}
|
||||
resp = self.client.ajax_post(self.url + '/0', grader)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
obj = json.loads(resp.content)
|
||||
self.assertEqual(obj, grader)
|
||||
current_graders = CourseGradingModel.fetch(self.course_locator).graders
|
||||
self.assertEqual(len(self.starting_graders), len(current_graders))
|
||||
|
||||
def test_add(self):
|
||||
"""Test adding a grading type record."""
|
||||
# the same url works for changing the whole grading model (graceperiod, cutoffs, and grading types) when
|
||||
# the grading_index is None; thus, using None to imply adding a grading_type doesn't work; so, it uses an
|
||||
# index out of bounds to imply create item.
|
||||
grader = {
|
||||
"type": "manual",
|
||||
"min_count": 5,
|
||||
@@ -462,6 +595,11 @@ class CourseGraderUpdatesTest(CourseTestCase):
|
||||
"short_label": "yo momma",
|
||||
"weight": 17.3,
|
||||
}
|
||||
resp = self.client.ajax_post(self.url, grader)
|
||||
resp = self.client.ajax_post('{}/{}'.format(self.url, len(self.starting_graders) + 1), grader)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
obj = json.loads(resp.content)
|
||||
self.assertEqual(obj['id'], len(self.starting_graders))
|
||||
del obj['id']
|
||||
self.assertEqual(obj, grader)
|
||||
current_graders = CourseGradingModel.fetch(self.course_locator).graders
|
||||
self.assertEqual(len(self.starting_graders) + 1, len(current_graders))
|
||||
|
||||
@@ -263,7 +263,7 @@ class ExportTestCase(CourseTestCase):
|
||||
parent_location=vertical.location,
|
||||
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):
|
||||
""" Export failure helper method. """
|
||||
|
||||
@@ -9,6 +9,7 @@ from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
|
||||
class ItemTest(CourseTestCase):
|
||||
@@ -30,7 +31,7 @@ class ItemTest(CourseTestCase):
|
||||
"""
|
||||
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))
|
||||
|
||||
def response_locator(self, response):
|
||||
@@ -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(unit1_locator).url(), children[2])
|
||||
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))
|
||||
|
||||
127
cms/djangoapps/contentstore/tests/test_permissions.py
Normal file
127
cms/djangoapps/contentstore/tests/test_permissions.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
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))
|
||||
@@ -20,6 +20,7 @@ from xmodule.contentstore.django import contentstore, _CONTENTSTORE
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
|
||||
from contentstore.tests.modulestore_config import TEST_MODULESTORE
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
@@ -59,7 +60,7 @@ class Basetranscripts(CourseTestCase):
|
||||
'type': 'video'
|
||||
}
|
||||
resp = self.client.ajax_post('/xblock', data)
|
||||
self.item_location = json.loads(resp.content).get('id')
|
||||
self.item_locator, self.item_location = self._get_locator(resp)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# hI10vDNYz4M - valid Youtube ID with transcripts.
|
||||
@@ -72,6 +73,11 @@ class Basetranscripts(CourseTestCase):
|
||||
# Remove all transcripts for current module.
|
||||
self.clear_subs_content()
|
||||
|
||||
def _get_locator(self, resp):
|
||||
""" Returns the locator and old-style location (as a string) from the response returned by a create operation. """
|
||||
locator = json.loads(resp.content).get('locator')
|
||||
return locator, loc_mapper().translate_locator_to_location(BlockUsageLocator(locator)).url()
|
||||
|
||||
def get_youtube_ids(self):
|
||||
"""Return youtube speeds and ids."""
|
||||
item = modulestore().get_item(self.item_location)
|
||||
@@ -136,7 +142,7 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'locator': self.item_locator,
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
@@ -158,20 +164,20 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
link = reverse('upload_transcripts')
|
||||
resp = self.client.post(link, {'file': self.good_srt_file})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'POST data without "id" form data.')
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'POST data without "locator" form data.')
|
||||
|
||||
def test_fail_data_without_file(self):
|
||||
link = reverse('upload_transcripts')
|
||||
resp = self.client.post(link, {'id': self.item_location})
|
||||
resp = self.client.post(link, {'locator': self.item_locator})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), 'POST data without "file" form data.')
|
||||
|
||||
def test_fail_data_with_bad_location(self):
|
||||
def test_fail_data_with_bad_locator(self):
|
||||
# Test for raising `InvalidLocationError` exception.
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': 'BAD_LOCATION',
|
||||
'locator': 'BAD_LOCATOR',
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
@@ -180,13 +186,13 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
}])
|
||||
})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by locator.")
|
||||
|
||||
# Test for raising `ItemNotFoundError` exception.
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION'),
|
||||
'locator': '{0}_{1}'.format(self.item_locator, 'BAD_LOCATOR'),
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
@@ -195,7 +201,7 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
}])
|
||||
})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by locator.")
|
||||
|
||||
def test_fail_for_non_video_module(self):
|
||||
# non_video module: setup
|
||||
@@ -205,7 +211,7 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
'type': 'non_video'
|
||||
}
|
||||
resp = self.client.ajax_post('/xblock', data)
|
||||
item_location = json.loads(resp.content).get('id')
|
||||
item_locator, item_location = self._get_locator(resp)
|
||||
data = '<non_video youtube="0.75:JMD_ifUUfsU,1.0:hI10vDNYz4M" />'
|
||||
modulestore().update_item(item_location, data)
|
||||
|
||||
@@ -214,7 +220,7 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': item_location,
|
||||
'locator': item_locator,
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
@@ -232,7 +238,7 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'locator': self.item_locator,
|
||||
'file': self.good_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
@@ -249,7 +255,7 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.bad_data_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'locator': self.item_locator,
|
||||
'file': self.bad_data_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
@@ -264,7 +270,7 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(self.bad_name_srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'locator': self.item_locator,
|
||||
'file': self.bad_name_srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
@@ -291,7 +297,7 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
link = reverse('upload_transcripts')
|
||||
filename = os.path.splitext(os.path.basename(srt_file.name))[0]
|
||||
resp = self.client.post(link, {
|
||||
'id': self.item_location,
|
||||
'locator': self.item_locator,
|
||||
'file': srt_file,
|
||||
'video_list': json.dumps([{
|
||||
'type': 'html5',
|
||||
@@ -353,7 +359,7 @@ class TestDownloadtranscripts(Basetranscripts):
|
||||
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location, 'subs_id': "JMD_ifUUfsU"})
|
||||
resp = self.client.get(link, {'locator': self.item_locator, 'subs_id': "JMD_ifUUfsU"})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.content, """0\n00:00:00,100 --> 00:00:00,200\nsubs #1\n\n1\n00:00:00,200 --> 00:00:00,240\nsubs #2\n\n2\n00:00:00,240 --> 00:00:00,380\nsubs #3\n\n""")
|
||||
|
||||
@@ -380,7 +386,7 @@ class TestDownloadtranscripts(Basetranscripts):
|
||||
self.save_subs_to_store(subs, subs_id)
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location, 'subs_id': subs_id})
|
||||
resp = self.client.get(link, {'locator': self.item_locator, 'subs_id': subs_id})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(
|
||||
resp.content,
|
||||
@@ -391,21 +397,21 @@ class TestDownloadtranscripts(Basetranscripts):
|
||||
|
||||
def test_fail_data_without_file(self):
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': ''})
|
||||
resp = self.client.get(link, {'locator': ''})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
resp = self.client.get(link, {})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_fail_data_with_bad_location(self):
|
||||
def test_fail_data_with_bad_locator(self):
|
||||
# Test for raising `InvalidLocationError` exception.
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': 'BAD_LOCATION'})
|
||||
resp = self.client.get(link, {'locator': 'BAD_LOCATOR'})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
# Test for raising `ItemNotFoundError` exception.
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION')})
|
||||
resp = self.client.get(link, {'locator': '{0}_{1}'.format(self.item_locator, 'BAD_LOCATOR')})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_fail_for_non_video_module(self):
|
||||
@@ -416,7 +422,7 @@ class TestDownloadtranscripts(Basetranscripts):
|
||||
'type': 'videoalpha'
|
||||
}
|
||||
resp = self.client.ajax_post('/xblock', data)
|
||||
item_location = json.loads(resp.content).get('id')
|
||||
item_locator, item_location = self._get_locator(resp)
|
||||
subs_id = str(uuid4())
|
||||
data = textwrap.dedent("""
|
||||
<videoalpha youtube="" sub="{}">
|
||||
@@ -439,7 +445,7 @@ class TestDownloadtranscripts(Basetranscripts):
|
||||
self.save_subs_to_store(subs, subs_id)
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': item_location})
|
||||
resp = self.client.get(link, {'locator': item_locator})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_fail_nonyoutube_subs_dont_exist(self):
|
||||
@@ -453,7 +459,7 @@ class TestDownloadtranscripts(Basetranscripts):
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location})
|
||||
resp = self.client.get(link, {'locator': self.item_locator})
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_empty_youtube_attr_and_sub_attr(self):
|
||||
@@ -467,7 +473,7 @@ class TestDownloadtranscripts(Basetranscripts):
|
||||
modulestore().update_item(self.item_location, data)
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location})
|
||||
resp = self.client.get(link, {'locator': self.item_locator})
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
@@ -492,7 +498,7 @@ class TestDownloadtranscripts(Basetranscripts):
|
||||
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
|
||||
|
||||
link = reverse('download_transcripts')
|
||||
resp = self.client.get(link, {'id': self.item_location})
|
||||
resp = self.client.get(link, {'locator': self.item_locator})
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
@@ -547,7 +553,7 @@ class TestChecktranscripts(Basetranscripts):
|
||||
self.save_subs_to_store(subs, subs_id)
|
||||
|
||||
data = {
|
||||
'id': self.item_location,
|
||||
'locator': self.item_locator,
|
||||
'videos': [{
|
||||
'type': 'html5',
|
||||
'video': subs_id,
|
||||
@@ -591,7 +597,7 @@ class TestChecktranscripts(Basetranscripts):
|
||||
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
|
||||
link = reverse('check_transcripts')
|
||||
data = {
|
||||
'id': self.item_location,
|
||||
'locator': self.item_locator,
|
||||
'videos': [{
|
||||
'type': 'youtube',
|
||||
'video': 'JMD_ifUUfsU',
|
||||
@@ -619,7 +625,7 @@ class TestChecktranscripts(Basetranscripts):
|
||||
def test_fail_data_without_id(self):
|
||||
link = reverse('check_transcripts')
|
||||
data = {
|
||||
'id': '',
|
||||
'locator': '',
|
||||
'videos': [{
|
||||
'type': '',
|
||||
'video': '',
|
||||
@@ -628,13 +634,13 @@ class TestChecktranscripts(Basetranscripts):
|
||||
}
|
||||
resp = self.client.get(link, {'data': json.dumps(data)})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by locator.")
|
||||
|
||||
def test_fail_data_with_bad_location(self):
|
||||
def test_fail_data_with_bad_locator(self):
|
||||
# Test for raising `InvalidLocationError` exception.
|
||||
link = reverse('check_transcripts')
|
||||
data = {
|
||||
'id': '',
|
||||
'locator': '',
|
||||
'videos': [{
|
||||
'type': '',
|
||||
'video': '',
|
||||
@@ -643,11 +649,11 @@ class TestChecktranscripts(Basetranscripts):
|
||||
}
|
||||
resp = self.client.get(link, {'data': json.dumps(data)})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by locator.")
|
||||
|
||||
# Test for raising `ItemNotFoundError` exception.
|
||||
data = {
|
||||
'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION'),
|
||||
'locator': '{0}_{1}'.format(self.item_locator, 'BAD_LOCATOR'),
|
||||
'videos': [{
|
||||
'type': '',
|
||||
'video': '',
|
||||
@@ -656,7 +662,7 @@ class TestChecktranscripts(Basetranscripts):
|
||||
}
|
||||
resp = self.client.get(link, {'data': json.dumps(data)})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
|
||||
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by locator.")
|
||||
|
||||
def test_fail_for_non_video_module(self):
|
||||
# Not video module: setup
|
||||
@@ -666,7 +672,7 @@ class TestChecktranscripts(Basetranscripts):
|
||||
'type': 'not_video'
|
||||
}
|
||||
resp = self.client.ajax_post('/xblock', data)
|
||||
item_location = json.loads(resp.content).get('id')
|
||||
item_locator, item_location = self._get_locator(resp)
|
||||
subs_id = str(uuid4())
|
||||
data = textwrap.dedent("""
|
||||
<not_video youtube="" sub="{}">
|
||||
@@ -689,7 +695,7 @@ class TestChecktranscripts(Basetranscripts):
|
||||
self.save_subs_to_store(subs, subs_id)
|
||||
|
||||
data = {
|
||||
'id': item_location,
|
||||
'locator': item_locator,
|
||||
'videos': [{
|
||||
'type': '',
|
||||
'video': '',
|
||||
|
||||
@@ -29,8 +29,8 @@ class UsersTestCase(CourseTestCase):
|
||||
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.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.inst_groupname = get_course_groupname_for_role(self.course.location, "instructor")
|
||||
self.staff_groupname = get_course_groupname_for_role(self.course_locator, "staff")
|
||||
self.inst_groupname = get_course_groupname_for_role(self.course_locator, "instructor")
|
||||
|
||||
def test_index(self):
|
||||
resp = self.client.get(self.index_url, HTTP_ACCEPT='text/html')
|
||||
@@ -145,18 +145,6 @@ class UsersTestCase(CourseTestCase):
|
||||
self.assertIn("error", result)
|
||||
self.assert_not_enrolled()
|
||||
|
||||
def test_detail_post_bad_json(self):
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data="{foo}",
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.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):
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
|
||||
@@ -10,8 +10,9 @@ from django.test.client import Client
|
||||
from django.test.utils import override_settings
|
||||
|
||||
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 xmodule.modulestore.django import loc_mapper
|
||||
|
||||
|
||||
def parse_json(response):
|
||||
@@ -41,6 +42,7 @@ class AjaxEnabledTestClient(Client):
|
||||
if not isinstance(data, basestring):
|
||||
data = json.dumps(data or {})
|
||||
kwargs.setdefault("HTTP_X_REQUESTED_WITH", "XMLHttpRequest")
|
||||
kwargs.setdefault("HTTP_ACCEPT", "application/json")
|
||||
return self.post(path=path, data=data, content_type=content_type, **kwargs)
|
||||
|
||||
def get_html(self, path, data=None, follow=False, **extra):
|
||||
@@ -88,6 +90,9 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
display_name='Robot Super Course',
|
||||
)
|
||||
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):
|
||||
"""
|
||||
@@ -106,3 +111,16 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
client = Client()
|
||||
client.login(username=uname, password=password)
|
||||
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
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.urlresolvers import reverse
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from django.http import HttpResponseNotFound
|
||||
@@ -22,6 +21,8 @@ from xmodule.modulestore.locator import BlockUsageLocator
|
||||
|
||||
__all__ = ['checklists_handler']
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -85,8 +86,8 @@ def checklists_handler(request, tag=None, course_id=None, branch=None, version_g
|
||||
return JsonResponse(expanded_checklist)
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
( "Could not save checklist state because the checklist index "
|
||||
"was out of range or unspecified."),
|
||||
("Could not save checklist state because the checklist index "
|
||||
"was out of range or unspecified."),
|
||||
content_type="text/plain"
|
||||
)
|
||||
else:
|
||||
@@ -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.
|
||||
"""
|
||||
expanded_checklist = copy.deepcopy(checklist)
|
||||
oldurlconf_map = {
|
||||
"SettingsDetails": "settings_details",
|
||||
"SettingsGrading": "settings_grading"
|
||||
}
|
||||
|
||||
urlconf_map = {
|
||||
"ManageUsers": "course_team",
|
||||
"CourseOutline": "course"
|
||||
"CourseOutline": "course",
|
||||
"SettingsDetails": "settings/details",
|
||||
"SettingsGrading": "settings/grading",
|
||||
}
|
||||
|
||||
for item in expanded_checklist.get('items'):
|
||||
@@ -130,12 +129,5 @@ def expand_checklist_action_url(course_module, checklist):
|
||||
ctx_loc = course_module.location
|
||||
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
|
||||
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
|
||||
|
||||
@@ -2,21 +2,19 @@ import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from django.http import (HttpResponse, HttpResponseBadRequest,
|
||||
HttpResponseForbidden)
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore.exceptions import (ItemNotFoundError,
|
||||
InvalidLocationError)
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
|
||||
from xblock.fields import Scope
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
@@ -25,7 +23,6 @@ from contentstore.utils import get_lms_link_for_item, compute_unit_state, UnitSt
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
from .helpers import _xmodule_recurse
|
||||
from .access import has_access
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xblock.plugin import PluginMissingError
|
||||
@@ -33,17 +30,13 @@ from xblock.runtime import Mixologist
|
||||
|
||||
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
'edit_subsection',
|
||||
'edit_unit',
|
||||
'assignment_type_update',
|
||||
'create_draft',
|
||||
'publish_draft',
|
||||
'unpublish_unit',
|
||||
'subsection_handler',
|
||||
'unit_handler'
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# NOTE: edit_unit assumes this list is disjoint from ADVANCED_COMPONENT_TYPES
|
||||
# NOTE: unit_handler assumes this list is disjoint from ADVANCED_COMPONENT_TYPES
|
||||
COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video']
|
||||
|
||||
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
|
||||
@@ -58,93 +51,87 @@ ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@login_required
|
||||
def edit_subsection(request, location):
|
||||
"Edit the subsection of a course"
|
||||
# check that we have permissions to edit this item
|
||||
try:
|
||||
course = get_course_for_item(location)
|
||||
except InvalidLocationError:
|
||||
return HttpResponseBadRequest()
|
||||
def subsection_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
The restful handler for subsection-specific requests.
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
GET
|
||||
html: return html page for editing a subsection
|
||||
json: not currently supported
|
||||
"""
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
try:
|
||||
old_location, course, item, lms_link = _get_item_in_course(request, locator)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
try:
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
preview_link = get_lms_link_for_item(old_location, course_id=course.location.course_id, preview=True)
|
||||
|
||||
lms_link = get_lms_link_for_item(
|
||||
location, course_id=course.location.course_id
|
||||
)
|
||||
preview_link = get_lms_link_for_item(
|
||||
location, course_id=course.location.course_id, preview=True
|
||||
)
|
||||
# make sure that location references a 'sequential', otherwise return
|
||||
# BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# make sure that location references a 'sequential', otherwise return
|
||||
# BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
parent_locs = modulestore().get_parent_locations(old_location, None)
|
||||
|
||||
parent_locs = modulestore().get_parent_locations(location, None)
|
||||
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error(
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error(
|
||||
'Multiple (or none) parents have been found for %s',
|
||||
location
|
||||
unicode(locator)
|
||||
)
|
||||
|
||||
# this should blow up if we don't find any parents, which would be erroneous
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a
|
||||
# more normalized UI. We only want to display the XBlocks fields, not
|
||||
# the fields from any mixins that have been added
|
||||
fields = getattr(item, 'unmixed_class', item.__class__).fields
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in fields.values()
|
||||
if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings
|
||||
)
|
||||
|
||||
# this should blow up if we don't find any parents, which would be erroneous
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
can_view_live = False
|
||||
subsection_units = item.get_children()
|
||||
for unit in subsection_units:
|
||||
state = compute_unit_state(unit)
|
||||
if state == UnitState.public or state == UnitState.draft:
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a
|
||||
# more normalized UI. We only want to display the XBlocks fields, not
|
||||
# the fields from any mixins that have been added
|
||||
fields = getattr(item, 'unmixed_class', item.__class__).fields
|
||||
course_locator = loc_mapper().translate_location(
|
||||
course.location.course_id, course.location, False, True
|
||||
)
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in fields.values()
|
||||
if field.name not in ['display_name', 'start', 'due', 'format']
|
||||
and field.scope == Scope.settings
|
||||
)
|
||||
|
||||
can_view_live = False
|
||||
subsection_units = item.get_children()
|
||||
for unit in subsection_units:
|
||||
state = compute_unit_state(unit)
|
||||
if state == UnitState.public or state == UnitState.draft:
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
locator = loc_mapper().translate_location(
|
||||
course.location.course_id, item.location, False, True
|
||||
)
|
||||
|
||||
return render_to_response(
|
||||
'edit_subsection.html',
|
||||
{
|
||||
'subsection': item,
|
||||
'context_course': course,
|
||||
'new_unit_category': 'vertical',
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
# For grader, which is not yet converted
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'locator': locator,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
}
|
||||
)
|
||||
return render_to_response(
|
||||
'edit_subsection.html',
|
||||
{
|
||||
'subsection': item,
|
||||
'context_course': course,
|
||||
'new_unit_category': 'vertical',
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course_locator).graders),
|
||||
'parent_item': parent,
|
||||
'locator': locator,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
}
|
||||
)
|
||||
else:
|
||||
return HttpResponseBadRequest("Only supports html requests")
|
||||
|
||||
|
||||
def load_mixed_class(category):
|
||||
def _load_mixed_class(category):
|
||||
"""
|
||||
Load an XBlock by category name, and apply all defined mixins
|
||||
"""
|
||||
@@ -153,139 +140,121 @@ def load_mixed_class(category):
|
||||
return mixologist.mix(component_class)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@login_required
|
||||
def edit_unit(request, location):
|
||||
def unit_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
Display an editing page for the specified module.
|
||||
The restful handler for unit-specific requests.
|
||||
|
||||
Expects a GET request with the parameter `id`.
|
||||
|
||||
id: A Location URL
|
||||
GET
|
||||
html: return html page for editing a unit
|
||||
json: not currently supported
|
||||
"""
|
||||
try:
|
||||
course = get_course_for_item(location)
|
||||
except InvalidLocationError:
|
||||
return HttpResponseBadRequest()
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
try:
|
||||
old_location, course, item, lms_link = _get_item_in_course(request, locator)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
try:
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
lms_link = get_lms_link_for_item(
|
||||
item.location,
|
||||
course_id=course.location.course_id
|
||||
)
|
||||
|
||||
# Note that the unit_state (draft, public, private) does not match up with the published value
|
||||
# passed to translate_location. The two concepts are different at this point.
|
||||
unit_locator = loc_mapper().translate_location(
|
||||
course.location.course_id, Location(location), False, True
|
||||
)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
for category in COMPONENT_TYPES:
|
||||
component_class = load_mixed_class(category)
|
||||
# add the default template
|
||||
# TODO: Once mixins are defined per-application, rather than per-runtime,
|
||||
# this should use a cms mixed-in class. (cpennington)
|
||||
if hasattr(component_class, 'display_name'):
|
||||
display_name = component_class.display_name.default or 'Blank'
|
||||
else:
|
||||
display_name = 'Blank'
|
||||
component_templates[category].append((
|
||||
display_name,
|
||||
category,
|
||||
False, # No defaults have markdown (hardcoded current default)
|
||||
None # no boilerplate for overrides
|
||||
))
|
||||
# add boilerplates
|
||||
if hasattr(component_class, 'templates'):
|
||||
for template in component_class.templates():
|
||||
filter_templates = getattr(component_class, 'filter_templates', None)
|
||||
if not filter_templates or filter_templates(template, course):
|
||||
component_templates[category].append((
|
||||
template['metadata'].get('display_name'),
|
||||
category,
|
||||
template['metadata'].get('markdown') is not None,
|
||||
template.get('template_id')
|
||||
))
|
||||
|
||||
# Check if there are any advanced modules specified in the course policy.
|
||||
# These modules should be specified as a list of strings, where the strings
|
||||
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
|
||||
# enabled for the course.
|
||||
course_advanced_keys = course.advanced_modules
|
||||
|
||||
# Set component types according to course policy file
|
||||
if isinstance(course_advanced_keys, list):
|
||||
for category in course_advanced_keys:
|
||||
if category in ADVANCED_COMPONENT_TYPES:
|
||||
# Do I need to allow for boilerplates or just defaults on the
|
||||
# class? i.e., can an advanced have more than one entry in the
|
||||
# menu? one for default and others for prefilled boilerplates?
|
||||
try:
|
||||
component_class = load_mixed_class(category)
|
||||
|
||||
component_templates['advanced'].append((
|
||||
component_class.display_name.default or category,
|
||||
category,
|
||||
False,
|
||||
None # don't override default data
|
||||
component_templates = defaultdict(list)
|
||||
for category in COMPONENT_TYPES:
|
||||
component_class = _load_mixed_class(category)
|
||||
# add the default template
|
||||
# TODO: Once mixins are defined per-application, rather than per-runtime,
|
||||
# this should use a cms mixed-in class. (cpennington)
|
||||
if hasattr(component_class, 'display_name'):
|
||||
display_name = component_class.display_name.default or 'Blank'
|
||||
else:
|
||||
display_name = 'Blank'
|
||||
component_templates[category].append((
|
||||
display_name,
|
||||
category,
|
||||
False, # No defaults have markdown (hardcoded current default)
|
||||
None # no boilerplate for overrides
|
||||
))
|
||||
# add boilerplates
|
||||
if hasattr(component_class, 'templates'):
|
||||
for template in component_class.templates():
|
||||
filter_templates = getattr(component_class, 'filter_templates', None)
|
||||
if not filter_templates or filter_templates(template, course):
|
||||
component_templates[category].append((
|
||||
template['metadata'].get('display_name'),
|
||||
category,
|
||||
template['metadata'].get('markdown') is not None,
|
||||
template.get('template_id')
|
||||
))
|
||||
except PluginMissingError:
|
||||
# dhm: I got this once but it can happen any time the
|
||||
# course author configures an advanced component which does
|
||||
# not exist on the server. This code here merely
|
||||
# prevents any authors from trying to instantiate the
|
||||
# non-existent component type by not showing it in the menu
|
||||
pass
|
||||
else:
|
||||
log.error(
|
||||
"Improper format for course advanced keys! %s",
|
||||
course_advanced_keys
|
||||
)
|
||||
|
||||
components = [
|
||||
[
|
||||
component.location.url(),
|
||||
# Check if there are any advanced modules specified in the course policy.
|
||||
# These modules should be specified as a list of strings, where the strings
|
||||
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
|
||||
# enabled for the course.
|
||||
course_advanced_keys = course.advanced_modules
|
||||
|
||||
# Set component types according to course policy file
|
||||
if isinstance(course_advanced_keys, list):
|
||||
for category in course_advanced_keys:
|
||||
if category in ADVANCED_COMPONENT_TYPES:
|
||||
# Do I need to allow for boilerplates or just defaults on the
|
||||
# class? i.e., can an advanced have more than one entry in the
|
||||
# menu? one for default and others for prefilled boilerplates?
|
||||
try:
|
||||
component_class = _load_mixed_class(category)
|
||||
|
||||
component_templates['advanced'].append(
|
||||
(
|
||||
component_class.display_name.default or category,
|
||||
category,
|
||||
False,
|
||||
None # don't override default data
|
||||
)
|
||||
)
|
||||
except PluginMissingError:
|
||||
# dhm: I got this once but it can happen any time the
|
||||
# course author configures an advanced component which does
|
||||
# not exist on the server. This code here merely
|
||||
# prevents any authors from trying to instantiate the
|
||||
# non-existent component type by not showing it in the menu
|
||||
pass
|
||||
else:
|
||||
log.error(
|
||||
"Improper format for course advanced keys! %s",
|
||||
course_advanced_keys
|
||||
)
|
||||
|
||||
components = [
|
||||
loc_mapper().translate_location(
|
||||
course.location.course_id, component.location, False, True
|
||||
)
|
||||
for component
|
||||
in item.get_children()
|
||||
]
|
||||
for component
|
||||
in item.get_children()
|
||||
]
|
||||
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
|
||||
containing_subsection_locs = modulestore().get_parent_locations(
|
||||
location, None
|
||||
)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
containing_section_locs = modulestore().get_parent_locations(
|
||||
containing_subsection_locs = modulestore().get_parent_locations(old_location, None)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
containing_section_locs = modulestore().get_parent_locations(
|
||||
containing_subsection.location, None
|
||||
)
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
)
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
|
||||
# need to figure out where this item is in the list of children as the
|
||||
# preview will need this
|
||||
index = 1
|
||||
for child in containing_subsection.get_children():
|
||||
if child.location == item.location:
|
||||
break
|
||||
index = index + 1
|
||||
# need to figure out where this item is in the list of children as the
|
||||
# preview will need this
|
||||
index = 1
|
||||
for child in containing_subsection.get_children():
|
||||
if child.location == item.location:
|
||||
break
|
||||
index = index + 1
|
||||
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE')
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE')
|
||||
|
||||
preview_lms_link = (
|
||||
preview_lms_link = (
|
||||
'//{preview_lms_base}/courses/{org}/{course}/'
|
||||
'{course_name}/courseware/{section}/{subsection}/{index}'
|
||||
).format(
|
||||
@@ -299,102 +268,46 @@ def edit_unit(request, location):
|
||||
index=index
|
||||
)
|
||||
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'unit': item,
|
||||
# Still needed for creating a draft.
|
||||
'unit_location': location,
|
||||
'unit_locator': unit_locator,
|
||||
'components': components,
|
||||
'component_templates': component_templates,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': (
|
||||
get_default_time_display(containing_subsection.start)
|
||||
if containing_subsection.start is not None else None
|
||||
),
|
||||
'section': containing_section,
|
||||
'new_unit_category': 'vertical',
|
||||
'unit_state': compute_unit_state(item),
|
||||
'published_date': (
|
||||
get_default_time_display(item.published_date)
|
||||
if item.published_date is not None else None
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@ensure_csrf_cookie
|
||||
def assignment_type_update(request, org, course, category, name):
|
||||
"""
|
||||
CRUD operations on assignment types for sections and subsections and
|
||||
anything else gradable.
|
||||
"""
|
||||
location = Location(['i4x', org, course, category, name])
|
||||
if not has_access(request.user, location):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
if request.method == 'GET':
|
||||
rsp = CourseGradingModel.get_section_grader_type(location)
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
rsp = CourseGradingModel.update_section_grader_type(
|
||||
location, request.json
|
||||
)
|
||||
return JsonResponse(rsp)
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'unit': item,
|
||||
'unit_locator': locator,
|
||||
'components': components,
|
||||
'component_templates': component_templates,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': (
|
||||
get_default_time_display(containing_subsection.start)
|
||||
if containing_subsection.start is not None else None
|
||||
),
|
||||
'section': containing_section,
|
||||
'new_unit_category': 'vertical',
|
||||
'unit_state': compute_unit_state(item),
|
||||
'published_date': (
|
||||
get_default_time_display(item.published_date)
|
||||
if item.published_date is not None else None
|
||||
),
|
||||
})
|
||||
else:
|
||||
return HttpResponseBadRequest("Only supports html requests")
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_draft(request):
|
||||
"Create a draft"
|
||||
location = request.json['id']
|
||||
def _get_item_in_course(request, locator):
|
||||
"""
|
||||
Helper method for getting the old location, containing course,
|
||||
item, and lms_link for a given locator.
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
Verifies that the caller has permission to access this item.
|
||||
"""
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
|
||||
# This clones the existing item location to a draft location (the draft is
|
||||
# implicit, because modulestore is a Draft modulestore)
|
||||
modulestore().convert_to_draft(location)
|
||||
old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
course_location = loc_mapper().translate_locator_to_location(locator, True)
|
||||
course = modulestore().get_item(course_location)
|
||||
item = modulestore().get_item(old_location, depth=1)
|
||||
lms_link = get_lms_link_for_item(old_location, course_id=course.location.course_id)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def publish_draft(request):
|
||||
"""
|
||||
Publish a draft
|
||||
"""
|
||||
location = request.json['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(
|
||||
item,
|
||||
lambda i: modulestore().publish(i.location, request.user.id)
|
||||
)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def unpublish_unit(request):
|
||||
"Unpublish a unit"
|
||||
location = request.json['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().unpublish(i.location))
|
||||
|
||||
return HttpResponse()
|
||||
return old_location, course, item, lms_link
|
||||
|
||||
@@ -27,13 +27,11 @@ from xmodule.modulestore.exceptions import (
|
||||
ItemNotFoundError, InvalidLocationError)
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from contentstore.course_info_model import (
|
||||
get_course_updates, update_course_updates, delete_course_update)
|
||||
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
|
||||
from contentstore.utils import (
|
||||
get_lms_link_for_item, add_extra_panel_tab, remove_extra_panel_tab,
|
||||
get_modulestore)
|
||||
from models.settings.course_details import (
|
||||
CourseDetails, CourseSettingsEncoder)
|
||||
from models.settings.course_details import CourseDetails, CourseSettingsEncoder
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
@@ -53,14 +51,13 @@ from student.models import CourseEnrollment
|
||||
from xmodule.html_module import AboutDescriptor
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
|
||||
from contentstore import utils
|
||||
|
||||
__all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler',
|
||||
'get_course_settings',
|
||||
'course_config_graders_page',
|
||||
'course_config_advanced_page',
|
||||
'course_settings_updates',
|
||||
'course_grader_updates',
|
||||
'course_advanced_updates', 'textbook_index', 'textbook_by_id',
|
||||
'settings_handler',
|
||||
'grading_handler',
|
||||
'advanced_settings_handler',
|
||||
'textbook_index', 'textbook_by_id',
|
||||
'create_textbook']
|
||||
|
||||
|
||||
@@ -177,7 +174,6 @@ def course_index(request, course_id, branch, version_guid, block):
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
old_location = loc_mapper().translate_locator_to_location(location)
|
||||
|
||||
lms_link = get_lms_link_for_item(old_location)
|
||||
@@ -190,10 +186,8 @@ def course_index(request, course_id, branch, version_guid, block):
|
||||
'lms_link': lms_link,
|
||||
'sections': sections,
|
||||
'course_graders': json.dumps(
|
||||
CourseGradingModel.fetch(course.location).graders
|
||||
CourseGradingModel.fetch(location).graders
|
||||
),
|
||||
# This is used by course grader, which has not yet been updated.
|
||||
'parent_location': course.location,
|
||||
'parent_locator': location,
|
||||
'new_section_category': 'chapter',
|
||||
'new_subsection_category': 'sequential',
|
||||
@@ -232,14 +226,20 @@ def create_new_course(request):
|
||||
pass
|
||||
if existing_course is not None:
|
||||
return JsonResponse({
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'ErrMsg': _(
|
||||
'There is already a course defined with the same '
|
||||
'organization, course number, and course run. Please '
|
||||
'change either organization or course number to be '
|
||||
'unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'unique.'
|
||||
),
|
||||
'OrgErrMsg': _(
|
||||
'Please change either the organization or '
|
||||
'course number so that it is unique.'
|
||||
),
|
||||
'CourseErrMsg': _(
|
||||
'Please change either the organization or '
|
||||
'course number so that it is unique.'
|
||||
),
|
||||
})
|
||||
|
||||
# dhm: this query breaks the abstraction, but I'll fix it when I do my suspended refactoring of this
|
||||
@@ -254,12 +254,15 @@ def create_new_course(request):
|
||||
courses = modulestore().collection.find(course_search_location, fields=('_id'))
|
||||
if courses.count() > 0:
|
||||
return JsonResponse({
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'ErrMsg': _(
|
||||
'There is already a course defined with the same '
|
||||
'organization and course number. Please '
|
||||
'change at least one field to be unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'OrgErrMsg': _(
|
||||
'Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'CourseErrMsg': _(
|
||||
'Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
})
|
||||
|
||||
@@ -289,7 +292,8 @@ def create_new_course(request):
|
||||
|
||||
initialize_course_tabs(new_course)
|
||||
|
||||
create_all_course_groups(request.user, new_course.location)
|
||||
new_location = loc_mapper().translate_location(new_course.location.course_id, new_course.location, False, True)
|
||||
create_all_course_groups(request.user, new_location)
|
||||
|
||||
# seed the forums
|
||||
seed_permissions_roles(new_course.location.course_id)
|
||||
@@ -298,7 +302,6 @@ def create_new_course(request):
|
||||
# work.
|
||||
CourseEnrollment.enroll(request.user, new_course.location.course_id)
|
||||
|
||||
new_location = loc_mapper().translate_location(new_course.location.course_id, new_course.location, False, True)
|
||||
return JsonResponse({'url': new_location.url_reverse("course/", "")})
|
||||
|
||||
|
||||
@@ -347,9 +350,8 @@ def course_info_handler(request, tag=None, course_id=None, branch=None, version_
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@expect_json
|
||||
def course_info_update_handler(
|
||||
request, tag=None, course_id=None, branch=None, version_guid=None, block=None, provided_id=None
|
||||
):
|
||||
def course_info_update_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None,
|
||||
provided_id=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates.
|
||||
provided_id should be none if it's new (create) and index otherwise.
|
||||
@@ -394,232 +396,206 @@ def course_info_update_handler(
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def get_course_settings(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to
|
||||
the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
new_loc = loc_mapper().translate_location(location.course_id, location, False, True)
|
||||
upload_asset_url = new_loc.url_reverse('assets/', '')
|
||||
|
||||
return render_to_response('settings.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'details_url': reverse(course_settings_updates,
|
||||
kwargs={"org": org,
|
||||
"course": course,
|
||||
"name": name,
|
||||
"section": "details"}),
|
||||
'about_page_editable': not settings.MITX_FEATURES.get(
|
||||
'ENABLE_MKTG_SITE', False
|
||||
),
|
||||
'upload_asset_url': upload_asset_url
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_graders_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to
|
||||
the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseGradingModel.fetch(location)
|
||||
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_advanced_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the advanced course
|
||||
settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'advanced_dict': json.dumps(CourseMetadata.fetch(location)),
|
||||
})
|
||||
|
||||
|
||||
@require_http_methods(("GET", "PUT", "POST"))
|
||||
@expect_json
|
||||
def settings_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
Course settings for dates and about pages
|
||||
GET
|
||||
html: get the page
|
||||
json: get the CourseDetails model
|
||||
PUT
|
||||
json: update the Course and About xblocks through the CourseDetails model
|
||||
"""
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
|
||||
course_old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
course_module = modulestore().get_item(course_old_location)
|
||||
|
||||
upload_asset_url = locator.url_reverse('assets/')
|
||||
|
||||
return render_to_response('settings.html', {
|
||||
'context_course': course_module,
|
||||
'course_locator': locator,
|
||||
'lms_link_for_about_page': utils.get_lms_link_for_about_page(course_old_location),
|
||||
'course_image_url': utils.course_image_url(course_module),
|
||||
'details_url': locator.url_reverse('/settings/details/'),
|
||||
'about_page_editable': not settings.MITX_FEATURES.get(
|
||||
'ENABLE_MKTG_SITE', False
|
||||
),
|
||||
'upload_asset_url': upload_asset_url
|
||||
})
|
||||
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(
|
||||
CourseDetails.fetch(locator),
|
||||
# encoder serializes dates, old locations, and instances
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
else: # post or put, doesn't matter.
|
||||
return JsonResponse(
|
||||
CourseDetails.update_from_json(locator, request.json),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_settings_updates(request, org, course, name, section):
|
||||
"""
|
||||
Restful CRUD operations on course settings. This differs from
|
||||
get_course_settings by communicating purely through json (not rendering any
|
||||
html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
section: one of details, faculty, grading, problems, discussions
|
||||
"""
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if section == 'details':
|
||||
manager = CourseDetails
|
||||
elif section == 'grading':
|
||||
manager = CourseGradingModel
|
||||
else:
|
||||
return
|
||||
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return JsonResponse(
|
||||
manager.fetch(Location(['i4x', org, course, 'course', name])),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
return JsonResponse(
|
||||
manager.update_from_json(request.json),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
|
||||
|
||||
@expect_json
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
"""
|
||||
Restful CRUD operations on course_info updates. This differs from
|
||||
get_course_settings by communicating purely through json (not rendering any
|
||||
html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return JsonResponse(CourseGradingModel.fetch_grader(
|
||||
Location(location), grader_index
|
||||
))
|
||||
elif request.method == "DELETE":
|
||||
# ??? Should this return anything? Perhaps success fail?
|
||||
CourseGradingModel.delete_grader(Location(location), grader_index)
|
||||
return JsonResponse()
|
||||
else: # post or put, doesn't matter.
|
||||
return JsonResponse(CourseGradingModel.update_grader_from_json(
|
||||
Location(location),
|
||||
request.json
|
||||
))
|
||||
|
||||
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@expect_json
|
||||
def course_advanced_updates(request, org, course, name):
|
||||
def grading_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None, grader_index=None):
|
||||
"""
|
||||
Restful CRUD operations on metadata. The payload is a json rep of the
|
||||
metadata dicts. For delete, otoh, the payload is either a key or a list of
|
||||
keys to delete.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
Course Grading policy configuration
|
||||
GET
|
||||
html: get the page
|
||||
json no grader_index: get the CourseGrading model (graceperiod, cutoffs, and graders)
|
||||
json w/ grader_index: get the specific grader
|
||||
PUT
|
||||
json no grader_index: update the Course through the CourseGrading model
|
||||
json w/ grader_index: create or update the specific grader (create if index out of range)
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(CourseMetadata.fetch(location))
|
||||
elif request.method == 'DELETE':
|
||||
return JsonResponse(CourseMetadata.delete_key(
|
||||
location,
|
||||
json.loads(request.body)
|
||||
))
|
||||
else:
|
||||
# Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
|
||||
course_old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
course_module = modulestore().get_item(course_old_location)
|
||||
course_details = CourseGradingModel.fetch(locator)
|
||||
|
||||
# Check to see if the user instantiated any advanced components. This
|
||||
# is a hack that does the following :
|
||||
# 1) adds/removes the open ended panel tab to a course automatically
|
||||
# if the user has indicated that they want to edit the
|
||||
# combinedopendended or peergrading module
|
||||
# 2) adds/removes the notes panel tab to a course automatically if
|
||||
# the user has indicated that they want the notes module enabled in
|
||||
# their course
|
||||
# TODO refactor the above into distinct advanced policy settings
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request.json:
|
||||
# Get the course so that we can scrape current tabs
|
||||
course_module = modulestore().get_item(location)
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_module,
|
||||
'course_locator': locator,
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder),
|
||||
'grading_url': locator.url_reverse('/settings/grading/'),
|
||||
})
|
||||
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
|
||||
if request.method == 'GET':
|
||||
if grader_index is None:
|
||||
return JsonResponse(
|
||||
CourseGradingModel.fetch(locator),
|
||||
# encoder serializes dates, old locations, and instances
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
else:
|
||||
return JsonResponse(CourseGradingModel.fetch_grader(locator, grader_index))
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
# None implies update the whole model (cutoffs, graceperiod, and graders) not a specific grader
|
||||
if grader_index is None:
|
||||
return JsonResponse(
|
||||
CourseGradingModel.update_from_json(locator, request.json),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
else:
|
||||
return JsonResponse(
|
||||
CourseGradingModel.update_grader_from_json(locator, request.json)
|
||||
)
|
||||
elif request.method == "DELETE" and grader_index is not None:
|
||||
CourseGradingModel.delete_grader(locator, grader_index)
|
||||
return JsonResponse()
|
||||
|
||||
# Maps tab types to components
|
||||
tab_component_map = {
|
||||
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
|
||||
'notes': NOTE_COMPONENT_TYPES,
|
||||
}
|
||||
|
||||
# Check to see if the user instantiated any notes or open ended
|
||||
# components
|
||||
for tab_type in tab_component_map.keys():
|
||||
component_types = tab_component_map.get(tab_type)
|
||||
found_ac_type = False
|
||||
for ac_type in component_types:
|
||||
if ac_type in request.json[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
# Add tab to the course if needed
|
||||
changed, new_tabs = add_extra_panel_tab(
|
||||
tab_type,
|
||||
course_module
|
||||
)
|
||||
# If a tab has been added to the course, then send the
|
||||
# metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request.json.update({'tabs': new_tabs})
|
||||
# Indicate that tabs should not be filtered out of
|
||||
# the metadata
|
||||
filter_tabs = False
|
||||
# Set this flag to avoid the tab removal code below.
|
||||
found_ac_type = True
|
||||
break
|
||||
# If we did not find a module type in the advanced settings,
|
||||
# we may need to remove the tab from the course.
|
||||
if not found_ac_type:
|
||||
# Remove tab from the course if needed
|
||||
changed, new_tabs = remove_extra_panel_tab(
|
||||
tab_type, course_module
|
||||
)
|
||||
# pylint: disable=invalid-name
|
||||
def _config_course_advanced_components(request, course_module):
|
||||
"""
|
||||
Check to see if the user instantiated any advanced components. This
|
||||
is a hack that does the following :
|
||||
1) adds/removes the open ended panel tab to a course automatically
|
||||
if the user has indicated that they want to edit the
|
||||
combinedopendended or peergrading module
|
||||
2) adds/removes the notes panel tab to a course automatically if
|
||||
the user has indicated that they want the notes module enabled in
|
||||
their course
|
||||
"""
|
||||
# TODO refactor the above into distinct advanced policy settings
|
||||
filter_tabs = True # Exceptional conditions will pull this to False
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request.json: # Maps tab types to components
|
||||
tab_component_map = {
|
||||
'open_ended':OPEN_ENDED_COMPONENT_TYPES,
|
||||
'notes':NOTE_COMPONENT_TYPES,
|
||||
}
|
||||
# Check to see if the user instantiated any notes or open ended
|
||||
# components
|
||||
for tab_type in tab_component_map.keys():
|
||||
component_types = tab_component_map.get(tab_type)
|
||||
found_ac_type = False
|
||||
for ac_type in component_types:
|
||||
if ac_type in request.json[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
# Add tab to the course if needed
|
||||
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
|
||||
# If a tab has been added to the course, then send the
|
||||
# metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request.json.update({'tabs': new_tabs})
|
||||
# Indicate that tabs should *not* be filtered out of
|
||||
# Indicate that tabs should not be filtered out of
|
||||
# the metadata
|
||||
filter_tabs = False
|
||||
try:
|
||||
return JsonResponse(CourseMetadata.update_from_json(
|
||||
location,
|
||||
request.json,
|
||||
filter_tabs=filter_tabs
|
||||
))
|
||||
except (TypeError, ValueError) as err:
|
||||
return HttpResponseBadRequest(
|
||||
"Incorrect setting format. " + str(err),
|
||||
content_type="text/plain"
|
||||
)
|
||||
filter_tabs = False # Set this flag to avoid the tab removal code below.
|
||||
found_ac_type = True #break
|
||||
|
||||
# If we did not find a module type in the advanced settings,
|
||||
# we may need to remove the tab from the course.
|
||||
if not found_ac_type: # Remove tab from the course if needed
|
||||
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request.json.update({'tabs':new_tabs})
|
||||
# Indicate that tabs should *not* be filtered out of
|
||||
# the metadata
|
||||
filter_tabs = False
|
||||
|
||||
return filter_tabs
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@expect_json
|
||||
def advanced_settings_handler(request, course_id=None, branch=None, version_guid=None, block=None, tag=None):
|
||||
"""
|
||||
Course settings configuration
|
||||
GET
|
||||
html: get the page
|
||||
json: get the model
|
||||
PUT, POST
|
||||
json: update the Course's settings. The payload is a json rep of the
|
||||
metadata dicts. The dict can include a "unsetKeys" entry which is a list
|
||||
of keys whose values to unset: i.e., revert to default
|
||||
"""
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
course_module = modulestore().get_item(course_old_location)
|
||||
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
|
||||
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'advanced_dict': json.dumps(CourseMetadata.fetch(course_module)),
|
||||
'advanced_settings_url': locator.url_reverse('settings/advanced')
|
||||
})
|
||||
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(CourseMetadata.fetch(course_module))
|
||||
else:
|
||||
# Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = _config_course_advanced_components(request, course_module)
|
||||
try:
|
||||
return JsonResponse(CourseMetadata.update_from_json(
|
||||
course_module,
|
||||
request.json,
|
||||
filter_tabs=filter_tabs
|
||||
))
|
||||
except (TypeError, ValueError) as err:
|
||||
return HttpResponseBadRequest(
|
||||
"Incorrect setting format. {}".format(err),
|
||||
content_type="text/plain"
|
||||
)
|
||||
|
||||
|
||||
class TextbookValidationError(Exception):
|
||||
|
||||
@@ -14,7 +14,6 @@ from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.servers.basehttp import FileWrapper
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
from django.core.exceptions import SuspiciousOperation, PermissionDenied
|
||||
@@ -140,7 +139,7 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid=
|
||||
"size": size,
|
||||
"deleteUrl": "",
|
||||
"deleteType": "",
|
||||
"url": location.url_reverse('import/', ''),
|
||||
"url": location.url_reverse('import'),
|
||||
"thumbnailUrl": ""
|
||||
}]
|
||||
})
|
||||
@@ -252,8 +251,8 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid=
|
||||
course_module = modulestore().get_item(old_location)
|
||||
return render_to_response('import.html', {
|
||||
'context_course': course_module,
|
||||
'successful_import_redirect_url': location.url_reverse("course/", ""),
|
||||
'import_status_url': location.url_reverse("import_status/", "fillerName"),
|
||||
'successful_import_redirect_url': location.url_reverse("course"),
|
||||
'import_status_url': location.url_reverse("import_status", "fillerName"),
|
||||
})
|
||||
else:
|
||||
return HttpResponseNotFound()
|
||||
@@ -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.
|
||||
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:
|
||||
name = old_location.name
|
||||
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=
|
||||
# if we have a nested exception, then we'll show the more generic error message
|
||||
pass
|
||||
|
||||
unit_locator = loc_mapper().translate_location(old_location.course_id, parent.location, False, True)
|
||||
|
||||
return render_to_response('export.html', {
|
||||
'context_course': course_module,
|
||||
'in_err': True,
|
||||
'raw_err_msg': str(e),
|
||||
'failed_module': failed_item,
|
||||
'unit': unit,
|
||||
'edit_unit_url': reverse('edit_unit', kwargs={
|
||||
'location': parent.location
|
||||
}) if parent else '',
|
||||
'course_home_url': location.url_reverse("course/", ""),
|
||||
'edit_unit_url': unit_locator.url_reverse("unit") if parent else "",
|
||||
'course_home_url': location.url_reverse("course"),
|
||||
'export_url': export_url
|
||||
|
||||
})
|
||||
@@ -359,7 +358,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid=
|
||||
'in_err': True,
|
||||
'unit': None,
|
||||
'raw_err_msg': str(e),
|
||||
'course_home_url': location.url_reverse("course/", ""),
|
||||
'course_home_url': location.url_reverse("course"),
|
||||
'export_url': export_url
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from functools import partial
|
||||
from static_replace import replace_static_urls
|
||||
from xmodule_modifiers import wrap_xblock
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -27,6 +29,9 @@ from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from student.models import CourseEnrollment
|
||||
from django.http import HttpResponseBadRequest
|
||||
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']
|
||||
|
||||
@@ -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.
|
||||
GET
|
||||
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
|
||||
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:
|
||||
:data: the new value for the data.
|
||||
: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
|
||||
to None! Absent ones will be left alone.
|
||||
: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.
|
||||
|
||||
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:
|
||||
:parent_locator: parent for new xblock, required
|
||||
:category: type of xblock, required
|
||||
@@ -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.
|
||||
"""
|
||||
if course_id is not None:
|
||||
location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, location):
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
old_location = loc_mapper().translate_locator_to_location(location)
|
||||
old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
|
||||
if request.method == 'GET':
|
||||
rsp = _get_module_info(location)
|
||||
return JsonResponse(rsp)
|
||||
if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
|
||||
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':
|
||||
delete_children = str_to_bool(request.REQUEST.get('recurse', '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=
|
||||
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.
|
||||
return _save_item(
|
||||
location,
|
||||
request,
|
||||
locator,
|
||||
old_location,
|
||||
data=request.json.get('data'),
|
||||
children=request.json.get('children'),
|
||||
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'):
|
||||
return _create_item(request)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
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:
|
||||
store.update_item(item_location, data)
|
||||
else:
|
||||
@@ -170,12 +217,25 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None
|
||||
if existing_item.category == 'video':
|
||||
manage_video_subtitles_save(existing_item, existing_item)
|
||||
|
||||
# Note that children aren't being returned until we have a use case.
|
||||
return JsonResponse({
|
||||
result = {
|
||||
'id': unicode(usage_loc),
|
||||
'data': data,
|
||||
'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
|
||||
@@ -192,10 +252,7 @@ def _create_item(request):
|
||||
raise PermissionDenied()
|
||||
|
||||
parent = get_modulestore(category).get_item(parent_location)
|
||||
# Necessary to set revision=None or else metadata inheritance does not work
|
||||
# (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)
|
||||
dest_location = parent_location.replace(category=category, name=uuid4().hex)
|
||||
|
||||
# get the metadata, display_name, and definition from the request
|
||||
metadata = {}
|
||||
@@ -224,7 +281,7 @@ def _create_item(request):
|
||||
|
||||
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)
|
||||
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):
|
||||
|
||||
@@ -3,7 +3,7 @@ from functools import partial
|
||||
|
||||
from django.conf import settings
|
||||
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 mitxmako.shortcuts import render_to_response, render_to_string
|
||||
|
||||
@@ -24,10 +24,9 @@ from util.sandboxing import can_execute_unsafe_code
|
||||
import static_replace
|
||||
from .session_kv_store import SessionKeyValueStore
|
||||
from .helpers import render_from_lms
|
||||
from .access import has_access
|
||||
from ..utils import get_course_for_item
|
||||
|
||||
__all__ = ['preview_handler', 'preview_component']
|
||||
__all__ = ['preview_handler']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -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`
|
||||
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)
|
||||
|
||||
descriptor = modulestore().get_item(location)
|
||||
instance = load_preview_module(request, descriptor)
|
||||
instance = _load_preview_module(request, descriptor)
|
||||
# Let the module handle the AJAX
|
||||
req = django_to_webob_request(request)
|
||||
try:
|
||||
@@ -85,32 +84,6 @@ def preview_handler(request, usage_id, handler, suffix=''):
|
||||
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
|
||||
"""
|
||||
An XModule ModuleSystem for use in Studio previews
|
||||
@@ -119,7 +92,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
|
||||
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
|
||||
rendering module previews.
|
||||
@@ -135,7 +108,7 @@ def preview_module_system(request, descriptor):
|
||||
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
|
||||
track_function=lambda event_type, event: None,
|
||||
filestore=descriptor.runtime.resources_fs,
|
||||
get_module=partial(load_preview_module, request),
|
||||
get_module=partial(_load_preview_module, request),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
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):
|
||||
)
|
||||
|
||||
|
||||
def load_preview_module(request, descriptor):
|
||||
def _load_preview_module(request, descriptor):
|
||||
"""
|
||||
Return a preview XModule instantiated from the supplied descriptor.
|
||||
|
||||
@@ -171,7 +144,7 @@ def load_preview_module(request, descriptor):
|
||||
"""
|
||||
student_data = DbModel(SessionKeyValueStore(request))
|
||||
descriptor.bind_for_student(
|
||||
preview_module_system(request, descriptor),
|
||||
_preview_module_system(request, descriptor),
|
||||
LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access
|
||||
)
|
||||
return descriptor
|
||||
@@ -182,7 +155,7 @@ def get_preview_html(request, descriptor):
|
||||
Returns the HTML returned by the XModule's student_view,
|
||||
specified by the descriptor and idx.
|
||||
"""
|
||||
module = load_preview_module(request, descriptor)
|
||||
module = _load_preview_module(request, descriptor)
|
||||
try:
|
||||
content = module.render("student_view").content
|
||||
except Exception as exc: # pylint: disable=W0703
|
||||
|
||||
@@ -10,7 +10,7 @@ from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from external_auth.views import ssl_login_shortcut
|
||||
|
||||
__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks']
|
||||
__all__ = ['signup', 'login_page', 'howitworks']
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@@ -22,13 +22,6 @@ def signup(request):
|
||||
return render_to_response('signup.html', {'csrf': csrf_token})
|
||||
|
||||
|
||||
def old_login_redirect(request):
|
||||
'''
|
||||
Redirect to the active login url.
|
||||
'''
|
||||
return redirect('login', permanent=True)
|
||||
|
||||
|
||||
@ssl_login_shortcut
|
||||
@ensure_csrf_cookie
|
||||
def login_page(request):
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
Views related to course tabs
|
||||
"""
|
||||
from access import has_access
|
||||
from util.json_request import expect_json
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
@@ -19,7 +20,7 @@ from ..utils import get_modulestore
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
__all__ = ['edit_tabs', 'reorder_static_tabs']
|
||||
__all__ = ['tabs_handler']
|
||||
|
||||
|
||||
def initialize_course_tabs(course):
|
||||
@@ -43,107 +44,113 @@ def initialize_course_tabs(course):
|
||||
|
||||
modulestore('direct').update_metadata(course.location.url(), own_metadata(course))
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def reorder_static_tabs(request):
|
||||
"Order the static tabs in the requested order"
|
||||
def get_location_for_tab(tab):
|
||||
tab_locator = BlockUsageLocator(tab)
|
||||
return loc_mapper().translate_locator_to_location(tab_locator)
|
||||
|
||||
tabs = request.json['tabs']
|
||||
course_location = loc_mapper().translate_locator_to_location(BlockUsageLocator(tabs[0]), get_course=True)
|
||||
|
||||
if not has_access(request.user, course_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
course = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
# get list of existing static tabs in course
|
||||
# make sure they are the same lengths (i.e. the number of passed in tabs equals the number
|
||||
# that we know about) otherwise we can drop some!
|
||||
|
||||
existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab']
|
||||
if len(existing_static_tabs) != len(tabs):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# load all reference tabs, return BadRequest if we can't find any of them
|
||||
tab_items = []
|
||||
for tab in tabs:
|
||||
item = modulestore('direct').get_item(get_location_for_tab(tab))
|
||||
if item is None:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
tab_items.append(item)
|
||||
|
||||
# now just go through the existing course_tabs and re-order the static tabs
|
||||
reordered_tabs = []
|
||||
static_tab_idx = 0
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reordered_tabs.append({'type': 'static_tab',
|
||||
'name': tab_items[static_tab_idx].display_name,
|
||||
'url_slug': tab_items[static_tab_idx].location.name})
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
reordered_tabs.append(tab)
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course.tabs = reordered_tabs
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course.save()
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
# TODO: above two lines are used for the primitive-save case. Maybe factor them out?
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def edit_tabs(request, org, course, coursename):
|
||||
"Edit tabs"
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
store = get_modulestore(location)
|
||||
course_item = store.get_item(location)
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
def tabs_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
The restful handler for static tabs.
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
GET
|
||||
html: return page for editing static tabs
|
||||
json: not supported
|
||||
PUT or POST
|
||||
json: update the tab order. It is expected that the request body contains a JSON-encoded dict with entry "tabs".
|
||||
The value for "tabs" is an array of tab locators, indicating the desired order of the tabs.
|
||||
|
||||
Creating a tab, deleting a tab, or changing its contents is not supported through this method.
|
||||
Instead use the general xblock URL (see item.xblock_handler).
|
||||
"""
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
|
||||
# see tabs have been uninitialized (e.g. supporing courses created before tab support in studio)
|
||||
if course_item.tabs is None or len(course_item.tabs) == 0:
|
||||
initialize_course_tabs(course_item)
|
||||
old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
store = get_modulestore(old_location)
|
||||
course_item = store.get_item(old_location)
|
||||
|
||||
# first get all static tabs from the tabs list
|
||||
# we do this because this is also the order in which items are displayed in the LMS
|
||||
static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
|
||||
if request.method == 'GET':
|
||||
raise NotImplementedError('coming soon')
|
||||
else:
|
||||
if 'tabs' in request.json:
|
||||
def get_location_for_tab(tab):
|
||||
""" Returns the location (old-style) for a tab. """
|
||||
return loc_mapper().translate_locator_to_location(BlockUsageLocator(tab))
|
||||
|
||||
static_tabs = []
|
||||
for static_tab_ref in static_tabs_refs:
|
||||
static_tab_loc = Location(location)._replace(category='static_tab', name=static_tab_ref['url_slug'])
|
||||
static_tabs.append(modulestore('direct').get_item(static_tab_loc))
|
||||
tabs = request.json['tabs']
|
||||
|
||||
components = [
|
||||
[
|
||||
static_tab.location.url(),
|
||||
# get list of existing static tabs in course
|
||||
# make sure they are the same lengths (i.e. the number of passed in tabs equals the number
|
||||
# that we know about) otherwise we will inadvertently drop some!
|
||||
existing_static_tabs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
if len(existing_static_tabs) != len(tabs):
|
||||
return JsonResponse(
|
||||
{"error": "number of tabs must be {}".format(len(existing_static_tabs))}, status=400
|
||||
)
|
||||
|
||||
# load all reference tabs, return BadRequest if we can't find any of them
|
||||
tab_items = []
|
||||
for tab in tabs:
|
||||
item = modulestore('direct').get_item(get_location_for_tab(tab))
|
||||
if item is None:
|
||||
return JsonResponse(
|
||||
{"error": "no tab for found location {}".format(tab)}, status=400
|
||||
)
|
||||
|
||||
tab_items.append(item)
|
||||
|
||||
# now just go through the existing course_tabs and re-order the static tabs
|
||||
reordered_tabs = []
|
||||
static_tab_idx = 0
|
||||
for tab in course_item.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reordered_tabs.append(
|
||||
{'type': 'static_tab',
|
||||
'name': tab_items[static_tab_idx].display_name,
|
||||
'url_slug': tab_items[static_tab_idx].location.name,
|
||||
}
|
||||
)
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
reordered_tabs.append(tab)
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course_item.tabs = reordered_tabs
|
||||
modulestore('direct').update_metadata(course_item.location, own_metadata(course_item))
|
||||
return JsonResponse()
|
||||
else:
|
||||
raise NotImplementedError('Creating or changing tab content is not supported.')
|
||||
elif request.method == 'GET': # assume html
|
||||
# see tabs have been uninitialized (e.g. supporting courses created before tab support in studio)
|
||||
if course_item.tabs is None or len(course_item.tabs) == 0:
|
||||
initialize_course_tabs(course_item)
|
||||
|
||||
# first get all static tabs from the tabs list
|
||||
# we do this because this is also the order in which items are displayed in the LMS
|
||||
static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
|
||||
static_tabs = []
|
||||
for static_tab_ref in static_tabs_refs:
|
||||
static_tab_loc = old_location.replace(category='static_tab', name=static_tab_ref['url_slug'])
|
||||
static_tabs.append(modulestore('direct').get_item(static_tab_loc))
|
||||
|
||||
components = [
|
||||
loc_mapper().translate_location(
|
||||
course_item.location.course_id, static_tab.location, False, True
|
||||
)
|
||||
for static_tab
|
||||
in static_tabs
|
||||
]
|
||||
for static_tab
|
||||
in static_tabs
|
||||
]
|
||||
|
||||
course_locator = loc_mapper().translate_location(
|
||||
course_item.location.course_id, course_item.location, False, True
|
||||
)
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'context_course': course_item,
|
||||
'components': components,
|
||||
'locator': course_locator
|
||||
})
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'context_course': course_item,
|
||||
'components': components,
|
||||
'course_locator': locator
|
||||
})
|
||||
else:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
|
||||
# "primitive" tab edit functions driven by the command line.
|
||||
@@ -167,7 +174,7 @@ def primitive_delete(course, num):
|
||||
# Note for future implementations: if you delete a static_tab, then Chris Dodge
|
||||
# points out that there's other stuff to delete beyond this element.
|
||||
# This code happens to not delete static_tab so it doesn't come up.
|
||||
primitive_save(course)
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
|
||||
|
||||
def primitive_insert(course, num, tab_type, name):
|
||||
@@ -176,11 +183,5 @@ def primitive_insert(course, num, tab_type, name):
|
||||
new_tab = {u'type': unicode(tab_type), u'name': unicode(name)}
|
||||
tabs = course.tabs
|
||||
tabs.insert(num, new_tab)
|
||||
primitive_save(course)
|
||||
|
||||
|
||||
def primitive_save(course):
|
||||
"Saves the course back to modulestore."
|
||||
# This code copied from reorder_static_tabs above
|
||||
course.save()
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
|
||||
|
||||
@@ -18,11 +18,12 @@ from django.conf import settings
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
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.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError
|
||||
|
||||
from util.json_request import JsonResponse
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
|
||||
from ..transcripts_utils import (
|
||||
generate_subs_from_source,
|
||||
@@ -77,20 +78,14 @@ def upload_transcripts(request):
|
||||
'subs': '',
|
||||
}
|
||||
|
||||
item_location = request.POST.get('id')
|
||||
if not item_location:
|
||||
return error_response(response, 'POST data without "id" form data.')
|
||||
locator = request.POST.get('locator')
|
||||
if not locator:
|
||||
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:
|
||||
item = modulestore().get_item(item_location)
|
||||
except (ItemNotFoundError, InvalidLocationError):
|
||||
return error_response(response, "Can't find item by location.")
|
||||
|
||||
# Check permissions for this user within this course.
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
item = _get_item(request, request.POST)
|
||||
except (ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError):
|
||||
return error_response(response, "Can't find item by locator.")
|
||||
|
||||
if 'file' not in request.FILES:
|
||||
return error_response(response, 'POST data without "file" form data.')
|
||||
@@ -156,23 +151,17 @@ def download_transcripts(request):
|
||||
|
||||
Raises Http404 if unsuccessful.
|
||||
"""
|
||||
item_location = request.GET.get('id')
|
||||
if not item_location:
|
||||
log.debug('GET data without "id" property.')
|
||||
locator = request.GET.get('locator')
|
||||
if not locator:
|
||||
log.debug('GET data without "locator" property.')
|
||||
raise Http404
|
||||
|
||||
# This is placed before has_access() to validate item_location,
|
||||
# because has_access() raises InvalidLocationError if location is invalid.
|
||||
try:
|
||||
item = modulestore().get_item(item_location)
|
||||
except (ItemNotFoundError, InvalidLocationError):
|
||||
log.debug("Can't find item by location.")
|
||||
item = _get_item(request, request.GET)
|
||||
except (ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError):
|
||||
log.debug("Can't find item by locator.")
|
||||
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')
|
||||
if not subs_id:
|
||||
log.debug('GET data without "subs_id" property.')
|
||||
@@ -240,7 +229,7 @@ def check_transcripts(request):
|
||||
'status': 'Error',
|
||||
}
|
||||
try:
|
||||
__, videos, item = validate_transcripts_data(request)
|
||||
__, videos, item = _validate_transcripts_data(request)
|
||||
except TranscriptsRequestValidationException as e:
|
||||
return error_response(transcripts_presence, e.message)
|
||||
|
||||
@@ -303,7 +292,7 @@ def check_transcripts(request):
|
||||
if len(html5_subs) == 2: # check html5 transcripts for equality
|
||||
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({
|
||||
'command': command,
|
||||
'subs': subs_to_use,
|
||||
@@ -311,7 +300,7 @@ def check_transcripts(request):
|
||||
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:
|
||||
|
||||
@@ -386,7 +375,7 @@ def choose_transcripts(request):
|
||||
}
|
||||
|
||||
try:
|
||||
data, videos, item = validate_transcripts_data(request)
|
||||
data, videos, item = _validate_transcripts_data(request)
|
||||
except TranscriptsRequestValidationException as e:
|
||||
return error_response(response, e.message)
|
||||
|
||||
@@ -416,7 +405,7 @@ def replace_transcripts(request):
|
||||
response = {'status': 'Error', 'subs': ''}
|
||||
|
||||
try:
|
||||
__, videos, item = validate_transcripts_data(request)
|
||||
__, videos, item = _validate_transcripts_data(request)
|
||||
except TranscriptsRequestValidationException as e:
|
||||
return error_response(response, e.message)
|
||||
|
||||
@@ -435,7 +424,7 @@ def replace_transcripts(request):
|
||||
return JsonResponse(response)
|
||||
|
||||
|
||||
def validate_transcripts_data(request):
|
||||
def _validate_transcripts_data(request):
|
||||
"""
|
||||
Validates, that request contains all proper data for transcripts processing.
|
||||
|
||||
@@ -452,18 +441,10 @@ def validate_transcripts_data(request):
|
||||
if not data:
|
||||
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:
|
||||
item = modulestore().get_item(item_location)
|
||||
except (ItemNotFoundError, InvalidLocationError):
|
||||
raise TranscriptsRequestValidationException("Can't find item by location.")
|
||||
|
||||
# Check permissions for this user within this course.
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
item = _get_item(request, data)
|
||||
except (ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError):
|
||||
raise TranscriptsRequestValidationException("Can't find item by locator.")
|
||||
|
||||
if item.category != 'video':
|
||||
raise TranscriptsRequestValidationException('Transcripts are supported only for "video" modules.')
|
||||
@@ -492,7 +473,7 @@ def rename_transcripts(request):
|
||||
response = {'status': 'Error', 'subs': ''}
|
||||
|
||||
try:
|
||||
__, videos, item = validate_transcripts_data(request)
|
||||
__, videos, item = _validate_transcripts_data(request)
|
||||
except TranscriptsRequestValidationException as e:
|
||||
return error_response(response, e.message)
|
||||
|
||||
@@ -525,11 +506,10 @@ def save_transcripts(request):
|
||||
if not data:
|
||||
return error_response(response, 'Incoming video data is empty.')
|
||||
|
||||
item_location = data.get('id')
|
||||
try:
|
||||
item = modulestore().get_item(item_location)
|
||||
except (ItemNotFoundError, InvalidLocationError):
|
||||
return error_response(response, "Can't find item by location.")
|
||||
item = _get_item(request, data)
|
||||
except (ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError):
|
||||
return error_response(response, "Can't find item by locator.")
|
||||
|
||||
metadata = data.get('metadata')
|
||||
if metadata is not None:
|
||||
@@ -553,3 +533,24 @@ def save_transcripts(request):
|
||||
response['status'] = 'Success'
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -10,9 +9,11 @@ from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
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 (
|
||||
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 .access import has_access
|
||||
@@ -35,6 +36,7 @@ def request_course_creator(request):
|
||||
return JsonResponse({"Status": "OK"})
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@@ -62,38 +64,39 @@ def course_team_handler(request, tag=None, course_id=None, branch=None, version_
|
||||
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
|
||||
"""
|
||||
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
|
||||
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()
|
||||
|
||||
course_module = modulestore().get_item(old_location)
|
||||
|
||||
staff_groupname = get_course_groupname_for_role(location, "staff")
|
||||
staff_group, __ = Group.objects.get_or_create(name=staff_groupname)
|
||||
inst_groupname = get_course_groupname_for_role(location, "instructor")
|
||||
inst_group, __ = Group.objects.get_or_create(name=inst_groupname)
|
||||
instructors = get_course_role_users(locator, INSTRUCTOR_ROLE_NAME)
|
||||
# the page only lists staff and assumes they're a superset of instructors. Do a union to ensure.
|
||||
staff = set(get_course_role_users(locator, STAFF_ROLE_NAME)).union(instructors)
|
||||
|
||||
return render_to_response('manage_users.html', {
|
||||
'context_course': course_module,
|
||||
'staff': staff_group.user_set.all(),
|
||||
'instructors': inst_group.user_set.all(),
|
||||
'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME),
|
||||
'staff': staff,
|
||||
'instructors': instructors,
|
||||
'allow_actions': has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME),
|
||||
})
|
||||
|
||||
|
||||
def _course_team_user(request, location, email):
|
||||
old_location = loc_mapper().translate_locator_to_location(location)
|
||||
@expect_json
|
||||
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
|
||||
if has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
if has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME):
|
||||
# instructors have full permissions
|
||||
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
|
||||
pass
|
||||
else:
|
||||
@@ -123,7 +126,7 @@ def _course_team_user(request, location, email):
|
||||
# what's the highest role that this user has?
|
||||
groupnames = set(g.name for g in user.groups.all())
|
||||
for role in roles:
|
||||
role_groupname = get_course_groupname_for_role(old_location, role)
|
||||
role_groupname = get_course_groupname_for_role(locator, role)
|
||||
if role_groupname in groupnames:
|
||||
msg["role"] = role
|
||||
break
|
||||
@@ -139,7 +142,7 @@ def _course_team_user(request, location, email):
|
||||
# make sure that the role groups exist
|
||||
groups = {}
|
||||
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)
|
||||
groups[role] = group
|
||||
|
||||
@@ -162,22 +165,13 @@ def _course_team_user(request, location, email):
|
||||
return JsonResponse()
|
||||
|
||||
# all other operations require the requesting user to specify a role
|
||||
if request.META.get("CONTENT_TYPE", "").startswith("application/json") and request.body:
|
||||
try:
|
||||
payload = json.loads(request.body)
|
||||
except:
|
||||
return JsonResponse({"error": _("malformed JSON")}, 400)
|
||||
try:
|
||||
role = payload["role"]
|
||||
except KeyError:
|
||||
return JsonResponse({"error": _("`role` is required")}, 400)
|
||||
else:
|
||||
if not "role" in request.POST:
|
||||
return JsonResponse({"error": _("`role` is required")}, 400)
|
||||
role = request.POST["role"]
|
||||
role = request.json.get("role", request.POST.get("role"))
|
||||
if role is None:
|
||||
return JsonResponse({"error": _("`role` is required")}, 400)
|
||||
|
||||
old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
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 = {
|
||||
"error": _("Only instructors may create other instructors")
|
||||
}
|
||||
@@ -203,4 +197,3 @@ def _course_team_user(request, location, email):
|
||||
CourseEnrollment.enroll(user, old_location.course_id)
|
||||
|
||||
return JsonResponse()
|
||||
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import re
|
||||
import logging
|
||||
import datetime
|
||||
import json
|
||||
from json.encoder import JSONEncoder
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
import json
|
||||
from json.encoder import JSONEncoder
|
||||
from contentstore.utils import get_modulestore, course_image_url
|
||||
from models.settings import course_grading
|
||||
from contentstore.utils import update_item
|
||||
from xmodule.fields import Date
|
||||
import re
|
||||
import logging
|
||||
import datetime
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
|
||||
|
||||
class CourseDetails(object):
|
||||
def __init__(self, location):
|
||||
self.course_location = location # a Location obj
|
||||
def __init__(self, org, course_id, run):
|
||||
# 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.end_date = None # 'end'
|
||||
self.enrollment_start = None
|
||||
@@ -27,16 +32,13 @@ class CourseDetails(object):
|
||||
self.course_image_asset_path = "" # URL of the course image
|
||||
|
||||
@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.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
course = cls(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_locator)
|
||||
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.start_date = descriptor.start
|
||||
course.end_date = descriptor.end
|
||||
@@ -45,7 +47,7 @@ class CourseDetails(object):
|
||||
course.course_image_name = descriptor.course_image
|
||||
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:
|
||||
course.syllabus = get_modulestore(temploc).get_item(temploc).data
|
||||
except ItemNotFoundError:
|
||||
@@ -73,14 +75,12 @@ class CourseDetails(object):
|
||||
return course
|
||||
|
||||
@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
|
||||
"""
|
||||
# TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
|
||||
course_location = Location(jsondict['course_location'])
|
||||
# Will probably want to cache the inflight courses because every blur generates an update
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_locator)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
dirty = False
|
||||
|
||||
@@ -134,11 +134,11 @@ class CourseDetails(object):
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
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
|
||||
# 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'])
|
||||
|
||||
temploc = temploc.replace(name='overview')
|
||||
@@ -151,9 +151,9 @@ class CourseDetails(object):
|
||||
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
|
||||
update_item(temploc, recomposed_video_tag)
|
||||
|
||||
# Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm
|
||||
# Could just return jsondict w/o doing any db reads, but I put the reads in as a means to confirm
|
||||
# it persisted correctly
|
||||
return CourseDetails.fetch(course_location)
|
||||
return CourseDetails.fetch(course_locator)
|
||||
|
||||
@staticmethod
|
||||
def parse_video_tag(raw_video):
|
||||
@@ -188,6 +188,9 @@ class CourseDetails(object):
|
||||
|
||||
# TODO move to a more general util?
|
||||
class CourseSettingsEncoder(json.JSONEncoder):
|
||||
"""
|
||||
Serialize CourseDetails, CourseGradingModel, datetime, and old Locations
|
||||
"""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)):
|
||||
return obj.__dict__
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
from datetime import timedelta
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xblock.fields import Scope
|
||||
|
||||
|
||||
class CourseGradingModel(object):
|
||||
@@ -9,22 +10,20 @@ class CourseGradingModel(object):
|
||||
"""
|
||||
# Within this class, allow access to protected members of client classes.
|
||||
# This comes up when accessing kvs data and caches during kvs saves and modulestore writes.
|
||||
# pylint: disable=W0212
|
||||
def __init__(self, course_descriptor):
|
||||
self.course_location = course_descriptor.location
|
||||
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
|
||||
self.graders = [
|
||||
CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)
|
||||
] # weights transformed to ints [0..100]
|
||||
self.grade_cutoffs = course_descriptor.grade_cutoffs
|
||||
self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor)
|
||||
|
||||
@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 grading policy for the given course from persistence and return a CourseGradingModel.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_locator)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
model = cls(descriptor)
|
||||
return model
|
||||
@@ -35,12 +34,8 @@ class CourseGradingModel(object):
|
||||
Fetch the course's nth grader
|
||||
Returns an empty dict if there's no such grader.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
# # ??? it would be good if these had the course_location in them so that they stand alone sufficiently
|
||||
# # but that would require not using CourseDescriptor's field directly. Opinions?
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
index = int(index)
|
||||
if len(descriptor.raw_grader) > index:
|
||||
@@ -57,48 +52,26 @@ class CourseGradingModel(object):
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def fetch_cutoffs(course_location):
|
||||
"""
|
||||
Fetch the course's grade cutoffs.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
return descriptor.grade_cutoffs
|
||||
|
||||
@staticmethod
|
||||
def fetch_grace_period(course_location):
|
||||
"""
|
||||
Fetch the course's default grace period.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
return {'grace_period': CourseGradingModel.convert_set_grace_period(descriptor)}
|
||||
|
||||
@staticmethod
|
||||
def update_from_json(jsondict):
|
||||
def update_from_json(course_locator, jsondict):
|
||||
"""
|
||||
Decode the json into CourseGradingModel and save any changes. Returns the modified model.
|
||||
Probably not the usual path for updates as it's too coarse grained.
|
||||
"""
|
||||
course_location = Location(jsondict['course_location'])
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_locator)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
|
||||
|
||||
descriptor.raw_grader = graders_parsed
|
||||
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
|
||||
|
||||
# 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_item(course_location, descriptor.xblock_kvs._data)
|
||||
get_modulestore(course_old_location).update_item(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
)
|
||||
|
||||
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
|
||||
CourseGradingModel.update_grace_period_from_json(course_locator, jsondict['grace_period'])
|
||||
|
||||
return CourseGradingModel.fetch(course_location)
|
||||
return CourseGradingModel.fetch(course_locator)
|
||||
|
||||
@staticmethod
|
||||
def update_grader_from_json(course_location, grader):
|
||||
@@ -106,12 +79,8 @@ class CourseGradingModel(object):
|
||||
Create or update the grader of the given type (string key) for the given course. Returns the modified
|
||||
grader which is a full model on the client but not on the server (just a dict)
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
# # ??? it would be good if these had the course_location in them so that they stand alone sufficiently
|
||||
# # but that would require not using CourseDescriptor's field directly. Opinions?
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
# parse removes the id; so, grab it before parse
|
||||
index = int(grader.get('id', len(descriptor.raw_grader)))
|
||||
@@ -122,10 +91,9 @@ class CourseGradingModel(object):
|
||||
else:
|
||||
descriptor.raw_grader.append(grader)
|
||||
|
||||
# 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_item(course_location, descriptor._field_data._kvs._data)
|
||||
get_modulestore(course_old_location).update_item(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
)
|
||||
|
||||
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
|
||||
|
||||
@@ -135,16 +103,13 @@ class CourseGradingModel(object):
|
||||
Create or update the grade cutoffs for the given course. Returns sent in cutoffs (ie., no extra
|
||||
db fetch).
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
descriptor.grade_cutoffs = cutoffs
|
||||
|
||||
# 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_item(course_location, descriptor._field_data._kvs._data)
|
||||
get_modulestore(course_old_location).update_item(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
)
|
||||
|
||||
return cutoffs
|
||||
|
||||
@@ -155,8 +120,8 @@ class CourseGradingModel(object):
|
||||
grace_period entry in an enclosing dict. It is also safe to call this method with a value of
|
||||
None for graceperiodjson.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
# Before a graceperiod has ever been created, it will be None (once it has been
|
||||
# created, it cannot be set back to None).
|
||||
@@ -164,81 +129,67 @@ class CourseGradingModel(object):
|
||||
if 'grace_period' in graceperiodjson:
|
||||
graceperiodjson = graceperiodjson['grace_period']
|
||||
|
||||
# lms requires these to be in a fixed order
|
||||
grace_timedelta = timedelta(**graceperiodjson)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
descriptor.graceperiod = grace_timedelta
|
||||
|
||||
# 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, descriptor._field_data._kvs._metadata)
|
||||
get_modulestore(course_old_location).update_metadata(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.settings)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_grader(course_location, index):
|
||||
"""
|
||||
Delete the grader of the given type from the given course.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
index = int(index)
|
||||
if index < len(descriptor.raw_grader):
|
||||
del descriptor.raw_grader[index]
|
||||
# force propagation to definition
|
||||
descriptor.raw_grader = descriptor.raw_grader
|
||||
|
||||
# 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_item(course_location, descriptor._field_data._kvs._data)
|
||||
get_modulestore(course_old_location).update_item(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_grace_period(course_location):
|
||||
"""
|
||||
Delete the course's default grace period.
|
||||
Delete the course's grace period.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
del descriptor.graceperiod
|
||||
|
||||
# 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, descriptor._field_data._kvs._metadata)
|
||||
get_modulestore(course_old_location).update_metadata(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.settings)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_section_grader_type(location):
|
||||
if not isinstance(location, Location):
|
||||
location = Location(location)
|
||||
|
||||
descriptor = get_modulestore(location).get_item(location)
|
||||
return {"graderType": descriptor.format if descriptor.format is not None else 'Not Graded',
|
||||
"location": location,
|
||||
"id": 99 # just an arbitrary value to
|
||||
}
|
||||
old_location = loc_mapper().translate_locator_to_location(location)
|
||||
descriptor = get_modulestore(old_location).get_item(old_location)
|
||||
return {
|
||||
"graderType": descriptor.format if descriptor.format is not None else 'Not Graded',
|
||||
"location": unicode(location),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_section_grader_type(location, jsondict):
|
||||
if not isinstance(location, Location):
|
||||
location = Location(location)
|
||||
|
||||
descriptor = get_modulestore(location).get_item(location)
|
||||
if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded":
|
||||
descriptor.format = jsondict.get('graderType')
|
||||
def update_section_grader_type(descriptor, grader_type):
|
||||
if grader_type is not None and grader_type != u"Not Graded":
|
||||
descriptor.format = grader_type
|
||||
descriptor.graded = True
|
||||
else:
|
||||
del descriptor.format
|
||||
del descriptor.graded
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(location).update_metadata(location, descriptor._field_data._kvs._metadata)
|
||||
get_modulestore(descriptor.location).update_metadata(
|
||||
descriptor.location, descriptor.get_explicitly_set_fields_by_scope(Scope.settings)
|
||||
)
|
||||
return {'graderType': grader_type}
|
||||
|
||||
@staticmethod
|
||||
def convert_set_grace_period(descriptor):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from xmodule.modulestore import Location
|
||||
from xblock.fields import Scope
|
||||
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xblock.fields import Scope
|
||||
from cms.xmodule_namespace import CmsBlockMixin
|
||||
|
||||
|
||||
@@ -20,21 +20,18 @@ class CourseMetadata(object):
|
||||
'tabs',
|
||||
'graceperiod',
|
||||
'checklists',
|
||||
'show_timezone'
|
||||
'show_timezone',
|
||||
'format',
|
||||
'graded',
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
def fetch(cls, descriptor):
|
||||
"""
|
||||
Fetch the key:value editable course details for the given course from
|
||||
persistence and return a CourseMetadata model.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
course = {}
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
result = {}
|
||||
|
||||
for field in descriptor.fields.values():
|
||||
if field.name in CmsBlockMixin.fields:
|
||||
@@ -46,19 +43,17 @@ class CourseMetadata(object):
|
||||
if field.name in cls.FILTERED_LIST:
|
||||
continue
|
||||
|
||||
course[field.name] = field.read_json(descriptor)
|
||||
result[field.name] = field.read_json(descriptor)
|
||||
|
||||
return course
|
||||
return result
|
||||
|
||||
@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.
|
||||
|
||||
Ensures none of the fields are in the blacklist.
|
||||
"""
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
dirty = False
|
||||
|
||||
# Copy the filtered list to avoid permanently changing the class attribute.
|
||||
@@ -72,39 +67,17 @@ class CourseMetadata(object):
|
||||
if key in filtered_list:
|
||||
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:
|
||||
dirty = True
|
||||
value = descriptor.fields[key].from_json(val)
|
||||
setattr(descriptor, key, value)
|
||||
|
||||
if dirty:
|
||||
# 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))
|
||||
get_modulestore(descriptor.location).update_metadata(descriptor.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')
|
||||
if SEGMENT_IO_KEY:
|
||||
MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False)
|
||||
|
||||
|
||||
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"]
|
||||
if AWS_SECRET_ACCESS_KEY == "":
|
||||
AWS_SECRET_ACCESS_KEY = None
|
||||
|
||||
DATABASES = AUTH_TOKENS['DATABASES']
|
||||
MODULESTORE = AUTH_TOKENS['MODULESTORE']
|
||||
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
|
||||
|
||||
@@ -23,7 +23,8 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
################################# LMS INTEGRATION #############################
|
||||
|
||||
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost:8000"
|
||||
LMS_BASE = "localhost:8000"
|
||||
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview." + LMS_BASE
|
||||
|
||||
################################# CELERY ######################################
|
||||
|
||||
|
||||
@@ -197,7 +197,8 @@ define([
|
||||
"js/spec/transcripts/videolist_spec", "js/spec/transcripts/message_manager_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
|
||||
# isolation issues with Squire.js
|
||||
|
||||
@@ -196,3 +196,22 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
|
||||
@handoutsEdit.$el.find('.edit-button').click()
|
||||
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)
|
||||
@@ -1,12 +1,9 @@
|
||||
define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) ->
|
||||
define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (ModuleEdit, ModuleModel) ->
|
||||
|
||||
describe "ModuleEdit", ->
|
||||
beforeEach ->
|
||||
@stubModule = jasmine.createSpy("Module")
|
||||
@stubModule.id = 'stub-id'
|
||||
@stubModule.get = (param)->
|
||||
if param == 'old_id'
|
||||
return 'stub-old-id'
|
||||
@stubModule = new ModuleModel
|
||||
id: "stub-id"
|
||||
|
||||
setFixtures """
|
||||
<li class="component" id="stub-id">
|
||||
@@ -62,7 +59,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) ->
|
||||
@moduleEdit.render()
|
||||
|
||||
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]()
|
||||
expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
|
||||
expect(@moduleEdit.delegateEvents).toHaveBeenCalled()
|
||||
|
||||
@@ -36,7 +36,7 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base
|
||||
|
||||
appendSetFixtures """
|
||||
<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>
|
||||
</li>
|
||||
</section>
|
||||
|
||||
@@ -69,15 +69,13 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
|
||||
payload
|
||||
(data) =>
|
||||
@model.set(id: data.locator)
|
||||
@model.set(old_id: data.id)
|
||||
@$el.data('id', data.id)
|
||||
@$el.data('locator', data.locator)
|
||||
@render()
|
||||
)
|
||||
|
||||
render: ->
|
||||
if @model.get('old_id')
|
||||
@$el.load("/preview_component/#{@model.get('old_id')}", =>
|
||||
if @model.id
|
||||
@$el.load(@model.url(), =>
|
||||
@loadDisplay()
|
||||
@delegateEvents()
|
||||
)
|
||||
|
||||
@@ -6,8 +6,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
|
||||
initialize: =>
|
||||
@$('.component').each((idx, element) =>
|
||||
model = new ModuleModel({
|
||||
id: $(element).data('locator'),
|
||||
old_id:$(element).data('id')
|
||||
id: $(element).data('locator')
|
||||
})
|
||||
|
||||
new ModuleEditView(
|
||||
@@ -38,14 +37,17 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
|
||||
analytics.track "Reordered Static Pages",
|
||||
course: course_location_analytics
|
||||
|
||||
saving = new NotificationView.Mini({title: gettext("Saving…")})
|
||||
saving.show()
|
||||
|
||||
$.ajax({
|
||||
type:'POST',
|
||||
url: '/reorder_static_tabs',
|
||||
url: @model.url(),
|
||||
data: JSON.stringify({
|
||||
tabs : tabs
|
||||
}),
|
||||
contentType: 'application/json'
|
||||
})
|
||||
}).success(=> saving.hide())
|
||||
|
||||
addNewTab: (event) =>
|
||||
event.preventDefault()
|
||||
|
||||
@@ -63,7 +63,6 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
@$('.component').each (idx, element) =>
|
||||
model = new ModuleModel
|
||||
id: $(element).data('locator')
|
||||
old_id: $(element).data('id')
|
||||
new ModuleEditView
|
||||
el: element,
|
||||
onDelete: @deleteComponent,
|
||||
@@ -167,7 +166,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
@wait(true)
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: @model.urlRoot + "/" + @$el.data('locator') + "?" + $.param({recurse: true})
|
||||
url: @model.url() + "?" + $.param({recurse: true})
|
||||
}).success(=>
|
||||
|
||||
analytics.track "Deleted Draft",
|
||||
@@ -180,8 +179,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
createDraft: (event) ->
|
||||
@wait(true)
|
||||
|
||||
$.postJSON('/create_draft', {
|
||||
id: @$el.data('id')
|
||||
$.postJSON(@model.url(), {
|
||||
publish: 'create_draft'
|
||||
}, =>
|
||||
analytics.track "Created Draft",
|
||||
course: course_location_analytics
|
||||
@@ -194,8 +193,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
@wait(true)
|
||||
@saveDraft()
|
||||
|
||||
$.postJSON('/publish_draft', {
|
||||
id: @$el.data('id')
|
||||
$.postJSON(@model.url(), {
|
||||
publish: 'make_public'
|
||||
}, =>
|
||||
analytics.track "Published Draft",
|
||||
course: course_location_analytics
|
||||
@@ -206,16 +205,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
|
||||
setVisibility: (event) ->
|
||||
if @$('.visibility-select').val() == 'private'
|
||||
target_url = '/unpublish_unit'
|
||||
action = 'make_private'
|
||||
visibility = "private"
|
||||
else
|
||||
target_url = '/publish_draft'
|
||||
action = 'make_public'
|
||||
visibility = "public"
|
||||
|
||||
@wait(true)
|
||||
|
||||
$.postJSON(target_url, {
|
||||
id: @$el.data('id')
|
||||
$.postJSON(@model.url(), {
|
||||
publish: action
|
||||
}, =>
|
||||
analytics.track "Set Unit Visibility",
|
||||
course: course_location_analytics
|
||||
|
||||
@@ -237,7 +237,7 @@ function createNewUnit(e) {
|
||||
|
||||
function(data) {
|
||||
// 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
|
||||
|
||||
var CourseGraderCollection = Backbone.Collection.extend({
|
||||
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() {
|
||||
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
define(["backbone", "underscore", "js/models/location"], function(Backbone, _, Location) {
|
||||
define(["backbone", "underscore"], function(Backbone, _) {
|
||||
var AssignmentGrade = Backbone.Model.extend({
|
||||
defaults : {
|
||||
graderType : null, // the type label (string). May be "Not Graded" which implies None. I'd like to use id but that's ephemeral
|
||||
location : null // A location object
|
||||
graderType : null, // the type label (string). May be "Not Graded" which implies None.
|
||||
locator : null // locator for the block
|
||||
},
|
||||
initialize : function(attrs) {
|
||||
if (attrs['assignmentUrl']) {
|
||||
this.set('location', new Location(attrs['assignmentUrl'], {parse: true}));
|
||||
}
|
||||
},
|
||||
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 "";
|
||||
idAttribute: 'locator',
|
||||
urlRoot : '/xblock/',
|
||||
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'});
|
||||
}
|
||||
});
|
||||
return AssignmentGrade;
|
||||
|
||||
@@ -5,12 +5,9 @@ define(["backbone"], function(Backbone) {
|
||||
url: '',
|
||||
|
||||
defaults: {
|
||||
"courseId": "", // the location url
|
||||
"updates" : null, // UpdateCollection
|
||||
"handouts": null // HandoutCollection
|
||||
},
|
||||
|
||||
idAttribute : "courseId"
|
||||
}
|
||||
});
|
||||
return CourseInfo;
|
||||
});
|
||||
|
||||
14
cms/static/js/models/explicit_url.js
Normal file
14
cms/static/js/models/explicit_url.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
define(["backbone", "underscore", "gettext", "js/models/location"], function(Backbone, _, gettext, Location) {
|
||||
define(["backbone", "underscore", "gettext"], function(Backbone, _, gettext) {
|
||||
|
||||
var CourseDetails = Backbone.Model.extend({
|
||||
defaults: {
|
||||
location : null, // the course's Location model, required
|
||||
org : '',
|
||||
course_id: '',
|
||||
run: '',
|
||||
start_date: null, // maps to 'start'
|
||||
end_date: null, // maps to 'end'
|
||||
enrollment_start: null,
|
||||
@@ -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)
|
||||
parse: function(attributes) {
|
||||
if (attributes['course_location']) {
|
||||
attributes.location = new Location(attributes.course_location, {parse:true});
|
||||
}
|
||||
if (attributes['start_date']) {
|
||||
attributes.start_date = new Date(attributes.start_date);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,11 @@ define(["backbone", "js/models/location", "js/collections/course_grader"],
|
||||
|
||||
var CourseGradingPolicy = Backbone.Model.extend({
|
||||
defaults : {
|
||||
course_location : null,
|
||||
graders : null, // CourseGraderCollection
|
||||
grade_cutoffs : null, // CourseGradeCutoff model
|
||||
grace_period : null // either null or { hours: n, minutes: m, ...}
|
||||
},
|
||||
parse: function(attributes) {
|
||||
if (attributes['course_location']) {
|
||||
attributes.course_location = new Location(attributes.course_location, {parse:true});
|
||||
}
|
||||
if (attributes['graders']) {
|
||||
var graderCollection;
|
||||
// interesting race condition: if {parse:true} when newing, then parse called before .attributes created
|
||||
@@ -21,7 +17,6 @@ var CourseGradingPolicy = Backbone.Model.extend({
|
||||
}
|
||||
else {
|
||||
graderCollection = new CourseGraderCollection(attributes.graders, {parse:true});
|
||||
graderCollection.course_location = attributes['course_location'] || this.get('course_location');
|
||||
}
|
||||
attributes.graders = graderCollection;
|
||||
}
|
||||
@@ -35,10 +30,6 @@ var CourseGradingPolicy = Backbone.Model.extend({
|
||||
}
|
||||
return attributes;
|
||||
},
|
||||
url : function() {
|
||||
var location = this.get('course_location');
|
||||
return '/' + location.get('org') + "/" + location.get('course') + '/settings-details/' + location.get('name') + '/section/grading';
|
||||
},
|
||||
gracePeriodToDate : function() {
|
||||
var newDate = new Date();
|
||||
if (this.has('grace_period') && this.get('grace_period')['hours'])
|
||||
|
||||
12
cms/static/js/spec/models/explicit_url_spec.js
Normal file
12
cms/static/js/spec/models/explicit_url_spec.js
Normal file
@@ -0,0 +1,12 @@
|
||||
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) {
|
||||
el: $container,
|
||||
messenger: messenger,
|
||||
videoListObject: videoListObject,
|
||||
component_id: 'component_id'
|
||||
component_locator: 'component_locator'
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
|
||||
view = new MessageManager({
|
||||
el: $container,
|
||||
parent: videoList,
|
||||
component_id: 'component_id'
|
||||
component_locator: 'component_locator'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
|
||||
expect(fileUploader.initialize).toHaveBeenCalledWith({
|
||||
el: view.$el,
|
||||
messenger: view,
|
||||
component_id: view.component_id,
|
||||
component_locator: view.component_locator,
|
||||
videoListObject: view.options.parent
|
||||
});
|
||||
});
|
||||
@@ -215,7 +215,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
|
||||
function() {
|
||||
expect(Utils.command).toHaveBeenCalledWith(
|
||||
action,
|
||||
view.component_id,
|
||||
view.component_locator,
|
||||
videoList,
|
||||
void(0)
|
||||
);
|
||||
@@ -245,7 +245,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
|
||||
function () {
|
||||
expect(Utils.command).toHaveBeenCalledWith(
|
||||
action,
|
||||
view.component_id,
|
||||
view.component_locator,
|
||||
videoList,
|
||||
{
|
||||
html5_id: extraParamas
|
||||
@@ -268,7 +268,7 @@ function ($, _, Utils, MessageManager, FileUploader, sinon) {
|
||||
function () {
|
||||
expect(Utils.command).toHaveBeenCalledWith(
|
||||
action,
|
||||
view.component_id,
|
||||
view.component_locator,
|
||||
videoList,
|
||||
void(0)
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s
|
||||
'transcripts/metadata-videolist-entry.underscore'
|
||||
),
|
||||
abstractEditor = AbstractEditor.prototype,
|
||||
component_id = 'component_id',
|
||||
component_locator = 'component_locator',
|
||||
videoList = [
|
||||
{
|
||||
mode: "youtube",
|
||||
@@ -62,7 +62,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s
|
||||
|
||||
var tpl = sandbox({
|
||||
'class': 'component',
|
||||
'data-id': component_id
|
||||
'data-locator': component_locator
|
||||
}),
|
||||
model = new MetadataModel(modelStub),
|
||||
videoList, $el;
|
||||
@@ -157,7 +157,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s
|
||||
waitsForResponse(function () {
|
||||
expect(abstractEditor.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');
|
||||
});
|
||||
});
|
||||
@@ -167,7 +167,7 @@ function ($, _, Utils, VideoList, MetadataView, MetadataModel, AbstractEditor, s
|
||||
expect(abstractEditor.render).toHaveBeenCalled();
|
||||
expect(Utils.command).toHaveBeenCalledWith(
|
||||
'check',
|
||||
component_id,
|
||||
component_locator,
|
||||
videoList
|
||||
);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification"
|
||||
model: this.model
|
||||
}))
|
||||
);
|
||||
$('.handouts-content').html(this.model.get('data'));
|
||||
this.$preview = this.$el.find('.handouts-content');
|
||||
this.$form = this.$el.find(".edit-handouts-form");
|
||||
this.$editor = this.$form.find('.handouts-content-editor');
|
||||
@@ -50,32 +51,43 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification"
|
||||
},
|
||||
|
||||
onSave: function(event) {
|
||||
this.model.set('data', this.$codeMirror.getValue());
|
||||
var saving = new NotificationView.Mini({
|
||||
title: gettext('Saving…')
|
||||
});
|
||||
saving.show();
|
||||
this.model.save({}, {
|
||||
success: function() {
|
||||
saving.hide();
|
||||
}
|
||||
});
|
||||
this.render();
|
||||
this.$form.hide();
|
||||
this.closeEditor();
|
||||
|
||||
analytics.track('Saved Course Handouts', {
|
||||
'course': course_location_analytics
|
||||
});
|
||||
$('#handout_error').removeClass('is-shown');
|
||||
$('.save-button').removeClass('is-disabled');
|
||||
if ($('.CodeMirror-lines').find('.cm-error').length == 0){
|
||||
this.model.set('data', this.$codeMirror.getValue());
|
||||
var saving = new NotificationView.Mini({
|
||||
title: gettext('Saving…')
|
||||
});
|
||||
saving.show();
|
||||
this.model.save({}, {
|
||||
success: function() {
|
||||
saving.hide();
|
||||
}
|
||||
});
|
||||
this.render();
|
||||
this.$form.hide();
|
||||
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) {
|
||||
$('#handout_error').removeClass('is-shown');
|
||||
$('.save-button').removeClass('is-disabled');
|
||||
this.$form.hide();
|
||||
this.closeEditor();
|
||||
},
|
||||
|
||||
closeEditor: function() {
|
||||
$('#handout_error').removeClass('is-shown');
|
||||
$('.save-button').removeClass('is-disabled');
|
||||
this.$form.hide();
|
||||
ModalUtils.hideModalCover();
|
||||
this.$form.find('.CodeMirror').remove();
|
||||
|
||||
@@ -6,7 +6,10 @@ define(["codemirror", "utility"],
|
||||
var $codeMirror = CodeMirror.fromTextArea(textArea, {
|
||||
mode: "text/html",
|
||||
lineNumbers: true,
|
||||
lineWrapping: true
|
||||
lineWrapping: true,
|
||||
onChange: function () {
|
||||
$('.save-button').removeClass('is-disabled');
|
||||
}
|
||||
});
|
||||
$codeMirror.setValue(content);
|
||||
$codeMirror.clearHistory();
|
||||
|
||||
@@ -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>' +
|
||||
'</ul>');
|
||||
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')});
|
||||
// TODO throw exception if graders is null
|
||||
this.graders = this.options['graders'];
|
||||
|
||||
@@ -21,9 +21,9 @@ var DetailsView = ValidatingView.extend({
|
||||
initialize : function() {
|
||||
this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="icon-file"></i><%= filename %></a>');
|
||||
// fill in fields
|
||||
this.$el.find("#course-name").val(this.model.get('location').get('name'));
|
||||
this.$el.find("#course-organization").val(this.model.get('location').get('org'));
|
||||
this.$el.find("#course-number").val(this.model.get('location').get('course'));
|
||||
this.$el.find("#course-organization").val(this.model.get('org'));
|
||||
this.$el.find("#course-number").val(this.model.get('course_id'));
|
||||
this.$el.find("#course-name").val(this.model.get('run'));
|
||||
this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
|
||||
|
||||
// Avoid showing broken image on mistyped/nonexistent image
|
||||
|
||||
@@ -72,7 +72,7 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
|
||||
syncBasicTab: function (metadataCollection, metadataView) {
|
||||
var result = [],
|
||||
getField = Utils.getField,
|
||||
component_id = this.$el.closest('.component').data('id'),
|
||||
component_locator = this.$el.closest('.component').data('locator'),
|
||||
subs = getField(metadataCollection, 'sub'),
|
||||
values = {},
|
||||
videoUrl, metadata, modifiedValues;
|
||||
@@ -99,7 +99,7 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
|
||||
if (isSubsModified) {
|
||||
metadata = $.extend(true, {}, modifiedValues);
|
||||
// Save module state
|
||||
Utils.command('save', component_id, null, {
|
||||
Utils.command('save', component_locator, null, {
|
||||
metadata: metadata,
|
||||
current_subs: _.pluck(
|
||||
Utils.getVideoList(videoUrl.getDisplayValue()),
|
||||
@@ -110,18 +110,16 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
|
||||
|
||||
// Get values from `Advanced` tab fields (`html5_sources`,
|
||||
// `youtube_id_1_0`) that should be synchronized.
|
||||
html5Sources = getField(metadataCollection, 'html5_sources')
|
||||
.getDisplayValue();
|
||||
var html5Sources = getField(metadataCollection, 'html5_sources').getDisplayValue();
|
||||
|
||||
values.youtube = getField(metadataCollection, 'youtube_id_1_0')
|
||||
.getDisplayValue();
|
||||
values.youtube = getField(metadataCollection, 'youtube_id_1_0').getDisplayValue();
|
||||
|
||||
values.html5Sources = _.filter(html5Sources, function (value) {
|
||||
var link = Utils.parseLink(value),
|
||||
values.html5Sources = _.filter(html5Sources, function (value) {
|
||||
var link = Utils.parseLink(value),
|
||||
mode = link && link.mode;
|
||||
|
||||
return mode === 'html5' && mode;
|
||||
});
|
||||
return mode === 'html5' && mode;
|
||||
});
|
||||
|
||||
|
||||
// The length of youtube video_id should be 11 characters.
|
||||
|
||||
@@ -39,7 +39,7 @@ function($, Backbone, _, Utils) {
|
||||
|
||||
tplContainer.html(this.template({
|
||||
ext: this.validFileExtensions,
|
||||
component_id: this.options.component_id,
|
||||
component_locator: this.options.component_locator,
|
||||
video_list: videoList
|
||||
}));
|
||||
|
||||
|
||||
@@ -31,12 +31,12 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
|
||||
initialize: function () {
|
||||
_.bindAll(this);
|
||||
|
||||
this.component_id = this.$el.closest('.component').data('id');
|
||||
this.component_locator = this.$el.closest('.component').data('locator');
|
||||
|
||||
this.fileUploader = new FileUploader({
|
||||
el: this.$el,
|
||||
messenger: this,
|
||||
component_id: this.component_id,
|
||||
component_locator: this.component_locator,
|
||||
videoListObject: this.options.parent
|
||||
});
|
||||
},
|
||||
@@ -76,7 +76,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
|
||||
this.$el.find('.transcripts-status')
|
||||
.removeClass('is-invisible')
|
||||
.find(this.elClass).html(template({
|
||||
component_id: encodeURIComponent(this.component_id),
|
||||
component_locator: encodeURIComponent(this.component_locator),
|
||||
html5_list: html5List,
|
||||
grouped_list: groupedList,
|
||||
subs_id: (params) ? params.subs: ''
|
||||
@@ -204,7 +204,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
|
||||
*/
|
||||
processCommand: function (action, errorMessage, videoId) {
|
||||
var self = this,
|
||||
component_id = this.component_id,
|
||||
component_locator = this.component_locator,
|
||||
videoList = this.options.parent.getVideoObjectsList(),
|
||||
extraParam, xhr;
|
||||
|
||||
@@ -212,7 +212,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
|
||||
extraParam = { html5_id: videoId };
|
||||
}
|
||||
|
||||
xhr = Utils.command(action, component_id, videoList, extraParam)
|
||||
xhr = Utils.command(action, component_locator, videoList, extraParam)
|
||||
.done(function (resp) {
|
||||
var sub = resp.subs;
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) {
|
||||
_.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 () {
|
||||
@@ -55,7 +55,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) {
|
||||
.apply(this, arguments);
|
||||
|
||||
var self = this,
|
||||
component_id = this.$el.closest('.component').data('id'),
|
||||
component_locator = this.$el.closest('.component').data('locator'),
|
||||
videoList = this.getVideoObjectsList(),
|
||||
|
||||
showServerError = function (response) {
|
||||
@@ -82,7 +82,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) {
|
||||
}
|
||||
|
||||
// Check current state of Timed Transcripts.
|
||||
Utils.command('check', component_id, videoList)
|
||||
Utils.command('check', component_locator, videoList)
|
||||
.done(function (resp) {
|
||||
var params = resp,
|
||||
len = videoList.length,
|
||||
|
||||
@@ -295,7 +295,7 @@ define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) {
|
||||
*
|
||||
* @param {string} action Action that will be invoked on server. Is a part
|
||||
* 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
|
||||
* urls.
|
||||
* @param {object} extraParams Extra parameters that can be send to the
|
||||
@@ -314,7 +314,7 @@ define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) {
|
||||
// _command() function.
|
||||
var xhr = null;
|
||||
|
||||
return function (action, component_id, videoList, extraParams) {
|
||||
return function (action, component_locator, videoList, extraParams) {
|
||||
var params, data;
|
||||
|
||||
if (extraParams) {
|
||||
@@ -326,7 +326,7 @@ define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) {
|
||||
}
|
||||
|
||||
data = $.extend(
|
||||
{ id: component_id },
|
||||
{ locator: component_locator },
|
||||
{ videos: videoList },
|
||||
params
|
||||
);
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
&.is-shown {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.is-hiding {
|
||||
bottom: -($ui-notification-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
// 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
|
||||
|
||||
@@ -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>
|
||||
<div class="modal-body">
|
||||
<h1 class="title">${_("Upload New File")}</h1>
|
||||
<p class="file-name"></a>
|
||||
<p class="file-name">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,6 @@ require(["domReady!", "jquery", "js/collections/course_update", "js/models/modul
|
||||
var editor = new CourseInfoEditView({
|
||||
el: $('.main-wrapper'),
|
||||
model : new CourseInfoModel({
|
||||
courseId : '${context_course.location}',
|
||||
updates : course_updates,
|
||||
base_asset_url : '${base_asset_url}',
|
||||
handouts : course_handouts
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
|
||||
<%block name="jsextra">
|
||||
<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({
|
||||
el: $('.main-wrapper'),
|
||||
model: new Backbone.Model({
|
||||
id: '${locator}'
|
||||
}),
|
||||
model: model,
|
||||
mast: $('.wrapper-mast')
|
||||
});
|
||||
});
|
||||
@@ -61,8 +64,8 @@ require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView)
|
||||
|
||||
<div class="tab-list">
|
||||
<ol class='components'>
|
||||
% for id, locator in components:
|
||||
<li class="component" data-id="${id}" data-locator="${locator}"/>
|
||||
% for locator in components:
|
||||
<li class="component" data-locator="${locator}"/>
|
||||
% endfor
|
||||
|
||||
<li class="new-component-item">
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="window-contents">
|
||||
<div class="scheduled-date-input row">
|
||||
@@ -115,7 +115,6 @@ require(["domReady!", "jquery", "js/models/location", "js/views/overview_assignm
|
||||
// but we really should change that behavior.
|
||||
if (!window.graderTypes) {
|
||||
window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true});
|
||||
window.graderTypes.course_location = new Location('${parent_location}');
|
||||
}
|
||||
|
||||
$(".gradable-status").each(function(index, ele) {
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
<h2 class="title">Course Handouts</h2>
|
||||
<%if (model.get('data') != null) { %>
|
||||
<div class="handouts-content">
|
||||
<%= model.get('data') %>
|
||||
|
||||
</div>
|
||||
<% } else {%>
|
||||
<p>${_("You have no handouts defined")}</p>
|
||||
<% } %>
|
||||
<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">
|
||||
<textarea class="handouts-content-editor text-editor"></textarea>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
method="post" enctype="multipart/form-data">
|
||||
<input type="file" class="file-input" name="file"
|
||||
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) %>'>
|
||||
</form>
|
||||
|
||||
@@ -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") %>">
|
||||
<span><%= gettext("Upload New Timed Transcript") %></span>
|
||||
</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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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") %>">
|
||||
<span><%= gettext("Upload New Timed Transcript") %></span>
|
||||
</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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,6 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
|
||||
// but we really should change that behavior.
|
||||
if (!window.graderTypes) {
|
||||
window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true});
|
||||
window.graderTypes.course_location = new Location('${parent_location}');
|
||||
}
|
||||
|
||||
$(".gradable-status").each(function(index, ele) {
|
||||
@@ -200,7 +199,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
|
||||
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}">
|
||||
|
||||
<%include file="widgets/_ui-dnd-indicator-before.html" />
|
||||
@@ -208,7 +207,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
|
||||
<div class="section-item">
|
||||
<div class="details">
|
||||
<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="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
|
||||
</a>
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from contentstore import utils
|
||||
from django.utils.translation import ugettext as _
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
|
||||
<%block name="jsextra">
|
||||
@@ -69,17 +67,20 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
|
||||
<ol class="list-input">
|
||||
<li class="field text is-not-editable" id="field-course-organization">
|
||||
<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 class="field text is-not-editable" id="field-course-number">
|
||||
<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 class="field text is-not-editable" id="field-course-name">
|
||||
<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>
|
||||
</ol>
|
||||
|
||||
@@ -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">
|
||||
<h3 class="title">${_("Course Summary Page")} <span class="tip">${_("(for student enrollment and access)")}</span></h3>
|
||||
<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>
|
||||
|
||||
<ul class="list-actions">
|
||||
<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"${context_course.display_name_with_default}",%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"${context_course.display_name_with_default}",%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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -199,7 +202,7 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
|
||||
<%def name='overview_text()'><%
|
||||
a_link_start = '<a class="link-courseURL" rel="external" href="'
|
||||
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}</%def>
|
||||
<span class="tip tip-stacked">${overview_text()}</span>
|
||||
@@ -211,15 +214,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
|
||||
<div class="current current-course-image">
|
||||
% if context_course.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>
|
||||
|
||||
<% ctx_loc = context_course.location %>
|
||||
<span class="msg msg-help">${_("You can manage this image along with all of your other")} <a href='${upload_asset_url}'>${_("files & uploads")}</a></span>
|
||||
<span class="msg msg-help">
|
||||
${_("You can manage this image along with all of your other <a href='{}'>files & uploads</a>").format(upload_asset_url)}
|
||||
</span>
|
||||
|
||||
% else:
|
||||
<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 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
|
||||
@@ -286,16 +290,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<%
|
||||
ctx_loc = context_course.location
|
||||
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
|
||||
course_team_url = location.url_reverse('course_team/', '')
|
||||
course_team_url = course_locator.url_reverse('course_team/', '')
|
||||
grading_config_url = course_locator.url_reverse('settings/grading/')
|
||||
advanced_config_url = course_locator.url_reverse('settings/advanced/')
|
||||
%>
|
||||
<h3 class="title-3">${_("Other Course Settings")}</h3>
|
||||
<nav class="nav-related">
|
||||
<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="${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>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore import utils
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
<%block name="title">${_("Advanced 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
|
||||
|
||||
// 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});
|
||||
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({
|
||||
el: $('.settings-advanced'),
|
||||
@@ -91,13 +89,15 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting
|
||||
<%
|
||||
ctx_loc = context_course.location
|
||||
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/', '')
|
||||
%>
|
||||
<h3 class="title-3">${_("Other Course Settings")}</h3>
|
||||
<nav class="nav-related">
|
||||
<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 & 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="${details_url}">${_("Details & Schedule")}</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>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
from contentstore import utils
|
||||
from django.utils.translation import ugettext as _
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
|
||||
<%block name="header_extras">
|
||||
@@ -28,9 +27,11 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
|
||||
$("label").removeClass("is-focused");
|
||||
});
|
||||
|
||||
var model = new CourseGradingPolicyModel(${course_details|n},{parse:true});
|
||||
model.urlRoot = '${grading_url}';
|
||||
var editor = new GradingView({
|
||||
el: $('.settings-grading'),
|
||||
model : new CourseGradingPolicyModel(${course_details|n},{parse:true})
|
||||
model : model
|
||||
});
|
||||
|
||||
editor.render();
|
||||
@@ -137,16 +138,16 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<%
|
||||
ctx_loc = context_course.location
|
||||
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
|
||||
course_team_url = location.url_reverse('course_team/', '')
|
||||
course_team_url = course_locator.url_reverse('course_team/')
|
||||
advanced_settings_url = course_locator.url_reverse('settings/advanced/')
|
||||
detailed_settings_url = course_locator.url_reverse('settings/details/')
|
||||
%>
|
||||
<h3 class="title-3">${_("Other Course Settings")}</h3>
|
||||
<nav class="nav-related">
|
||||
<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 & Schedule")}</a></li>
|
||||
<li class="nav-item"><a href="${detailed_settings_url}">${_("Details & Schedule")}</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>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -34,7 +34,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
|
||||
</%block>
|
||||
<%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="alert editing-draft-alert">
|
||||
<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"
|
||||
<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>
|
||||
<ol class="components">
|
||||
% for id, locator in components:
|
||||
<li class="component" data-id="${id}" data-locator="${locator}"/>
|
||||
% for locator in components:
|
||||
<li class="component" data-locator="${locator}"/>
|
||||
% endfor
|
||||
<li class="new-component-item adding">
|
||||
<div class="new-component">
|
||||
@@ -135,6 +135,13 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
</article>
|
||||
</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="unit-settings window">
|
||||
<h4 class="header">${_("Unit Settings")}</h4>
|
||||
@@ -157,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
% endif
|
||||
${_("with the subsection {link_start}{name}{link_end}").format(
|
||||
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>',
|
||||
)}
|
||||
</p>
|
||||
@@ -175,19 +182,15 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
<div class="row wrapper-unit-id">
|
||||
<p class="unit-id">
|
||||
<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>
|
||||
</div>
|
||||
<ol>
|
||||
<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>
|
||||
<ol>
|
||||
<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="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
|
||||
</a>
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
<%
|
||||
ctx_loc = context_course.location
|
||||
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
|
||||
index_url = location.url_reverse('course/')
|
||||
checklists_url = location.url_reverse('checklists/')
|
||||
course_team_url = location.url_reverse('course_team/')
|
||||
assets_url = location.url_reverse('assets/')
|
||||
import_url = location.url_reverse('import/')
|
||||
course_info_url = location.url_reverse('course_info/')
|
||||
export_url = location.url_reverse('export/', '')
|
||||
index_url = location.url_reverse('course')
|
||||
checklists_url = location.url_reverse('checklists')
|
||||
course_team_url = location.url_reverse('course_team')
|
||||
assets_url = location.url_reverse('assets')
|
||||
import_url = location.url_reverse('import')
|
||||
course_info_url = location.url_reverse('course_info')
|
||||
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">
|
||||
<span class="sr">${_("Current Course:")}</span>
|
||||
@@ -48,7 +52,7 @@
|
||||
<a href="${course_info_url}">${_("Updates")}</a>
|
||||
</li>
|
||||
<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 class="nav-item nav-course-courseware-uploads">
|
||||
<a href="${assets_url}">${_("Files & Uploads")}</a>
|
||||
@@ -68,16 +72,16 @@
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<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 & Details")}</a>
|
||||
<a href="${settings_url}">${_("Schedule & Details")}</a>
|
||||
</li>
|
||||
<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 class="nav-item nav-course-settings-team">
|
||||
<a href="${course_team_url}">${_("Course Team")}</a>
|
||||
</li>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="row">
|
||||
<h6>${_("Heading 1")}</h6>
|
||||
<div class="col sample heading-1">
|
||||
<img src="${static.url("/img/header-example.png")}" />
|
||||
<img src="${static.url("img/header-example.png")}" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>H1
|
||||
@@ -75,7 +75,9 @@
|
||||
<img src="${static.url("img/string-example.png")}" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>= dog</code></pre>
|
||||
<pre><code>= dog
|
||||
or= cat
|
||||
or= mouse</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<%!
|
||||
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'):
|
||||
<!-- begin Segment.io -->
|
||||
<script type="text/javascript">
|
||||
// if inside course, inject the course location into the JS namespace
|
||||
%if context_course:
|
||||
var course_location_analytics = "${context_course.location}";
|
||||
var course_location_analytics = "${locator}";
|
||||
%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])};
|
||||
@@ -22,7 +33,7 @@
|
||||
<!-- dummy segment.io -->
|
||||
<script type="text/javascript">
|
||||
%if context_course:
|
||||
var course_location_analytics = "${context_course.location}";
|
||||
var course_location_analytics = "${locator}";
|
||||
%endif
|
||||
var analytics = {
|
||||
"track": function() {}
|
||||
|
||||
@@ -31,7 +31,7 @@ This def will enumerate through a passed in subsection and list all of the units
|
||||
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-name">${unit.display_name_with_default}</span>
|
||||
</a>
|
||||
|
||||
47
cms/urls.py
47
cms/urls.py
@@ -11,10 +11,6 @@ from ratelimitbackend import admin
|
||||
admin.autodiscover()
|
||||
|
||||
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/download$', 'contentstore.views.download_transcripts', name='download_transcripts'),
|
||||
@@ -24,35 +20,9 @@ urlpatterns = patterns('', # nopep8
|
||||
url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_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>[^/]*))?$',
|
||||
'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>[^/]+)$',
|
||||
'contentstore.views.textbook_index', name='textbook_index'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/new$',
|
||||
@@ -79,18 +49,12 @@ urlpatterns = patterns('', # nopep8
|
||||
# User creation and updating views
|
||||
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'^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
|
||||
url(r'^login_post$', 'student.views.login_user', name='login_post'),
|
||||
|
||||
url(r'^logout$', 'student.views.logout_user', name='logout'),
|
||||
)
|
||||
|
||||
@@ -98,7 +62,12 @@ urlpatterns += patterns(
|
||||
urlpatterns += patterns(
|
||||
'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'),
|
||||
|
||||
# (?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_info/{}$'.format(parsers.URL_RE_SOURCE), 'course_info_handler'),
|
||||
@@ -107,6 +76,8 @@ urlpatterns += patterns(
|
||||
'course_info_update_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)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'),
|
||||
url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'),
|
||||
@@ -114,6 +85,10 @@ urlpatterns += patterns(
|
||||
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)^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 = {
|
||||
|
||||
142
common/djangoapps/external_auth/tests/test_ssl.py
Normal file
142
common/djangoapps/external_auth/tests/test_ssl.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
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
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_CAS'):
|
||||
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.utils.http import urlquote, is_safe_url
|
||||
@@ -250,6 +250,18 @@ def _signup(request, eamap):
|
||||
# save this for use by student.views.create_account
|
||||
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
|
||||
# but this only affects username, not fullname
|
||||
username = re.sub(r'\s', '', _flatten_to_ascii(eamap.external_name), flags=re.UNICODE)
|
||||
@@ -880,146 +892,7 @@ def provider_xrds(request):
|
||||
return response
|
||||
|
||||
|
||||
#-------------------
|
||||
# Pearson
|
||||
#-------------------
|
||||
def course_from_id(course_id):
|
||||
"""Return the CourseDescriptor corresponding to this course_id"""
|
||||
course_loc = CourseDescriptor.id_to_location(course_id)
|
||||
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)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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)
|
||||
@@ -1,111 +0,0 @@
|
||||
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()
|
||||
@@ -1,103 +0,0 @@
|
||||
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()
|
||||
@@ -1,119 +0,0 @@
|
||||
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)
|
||||
@@ -1,206 +0,0 @@
|
||||
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."
|
||||
@@ -1,190 +0,0 @@
|
||||
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."
|
||||
@@ -1,167 +0,0 @@
|
||||
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()
|
||||
@@ -1,69 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
)
|
||||
@@ -1,380 +0,0 @@
|
||||
'''
|
||||
Created on Jan 17, 2013
|
||||
|
||||
@author: brian
|
||||
'''
|
||||
import logging
|
||||
import os
|
||||
from tempfile import mkdtemp
|
||||
import cStringIO
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.management import call_command
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
from student.models import User, TestCenterUser, get_testcenter_registration
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_tc_user(username):
|
||||
user = User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass')
|
||||
options = {
|
||||
'first_name': 'TestFirst',
|
||||
'last_name': 'TestLast',
|
||||
'address_1': 'Test Address',
|
||||
'city': 'TestCity',
|
||||
'state': 'Alberta',
|
||||
'postal_code': 'A0B 1C2',
|
||||
'country': 'CAN',
|
||||
'phone': '252-1866',
|
||||
'phone_country_code': '1',
|
||||
}
|
||||
call_command('pearson_make_tc_user', username, **options)
|
||||
return TestCenterUser.objects.get(user=user)
|
||||
|
||||
|
||||
def create_tc_registration(username, course_id='org1/course1/term1', exam_code='exam1', accommodation_code=None):
|
||||
|
||||
options = {'exam_series_code': exam_code,
|
||||
'eligibility_appointment_date_first': '2013-01-01T00:00',
|
||||
'eligibility_appointment_date_last': '2013-12-31T23:59',
|
||||
'accommodation_code': accommodation_code,
|
||||
'create_dummy_exam': True,
|
||||
}
|
||||
|
||||
call_command('pearson_make_tc_registration', username, course_id, **options)
|
||||
user = User.objects.get(username=username)
|
||||
registrations = get_testcenter_registration(user, course_id, exam_code)
|
||||
return registrations[0]
|
||||
|
||||
|
||||
def create_multiple_registrations(prefix='test'):
|
||||
username1 = '{}_multiple1'.format(prefix)
|
||||
create_tc_user(username1)
|
||||
create_tc_registration(username1)
|
||||
create_tc_registration(username1, course_id='org1/course2/term1')
|
||||
create_tc_registration(username1, exam_code='exam2')
|
||||
username2 = '{}_multiple2'.format(prefix)
|
||||
create_tc_user(username2)
|
||||
create_tc_registration(username2)
|
||||
username3 = '{}_multiple3'.format(prefix)
|
||||
create_tc_user(username3)
|
||||
create_tc_registration(username3, course_id='org1/course2/term1')
|
||||
username4 = '{}_multiple4'.format(prefix)
|
||||
create_tc_user(username4)
|
||||
create_tc_registration(username4, exam_code='exam2')
|
||||
|
||||
|
||||
def get_command_error_text(*args, **options):
|
||||
stderr_string = None
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = cStringIO.StringIO()
|
||||
try:
|
||||
call_command(*args, **options)
|
||||
except SystemExit, why1:
|
||||
# The goal here is to catch CommandError calls.
|
||||
# But these are actually translated into nice messages,
|
||||
# and sys.exit(1) is then called. For testing, we
|
||||
# want to catch what sys.exit throws, and get the
|
||||
# relevant text either from stdout or stderr.
|
||||
if (why1.message > 0):
|
||||
stderr_string = sys.stderr.getvalue()
|
||||
else:
|
||||
raise why1
|
||||
except Exception, why:
|
||||
raise why
|
||||
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
if stderr_string is None:
|
||||
raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0]))
|
||||
return stderr_string
|
||||
|
||||
|
||||
def get_error_string_for_management_call(*args, **options):
|
||||
stdout_string = None
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
sys.stdout = cStringIO.StringIO()
|
||||
sys.stderr = cStringIO.StringIO()
|
||||
try:
|
||||
call_command(*args, **options)
|
||||
except SystemExit, why1:
|
||||
# The goal here is to catch CommandError calls.
|
||||
# But these are actually translated into nice messages,
|
||||
# and sys.exit(1) is then called. For testing, we
|
||||
# want to catch what sys.exit throws, and get the
|
||||
# relevant text either from stdout or stderr.
|
||||
if (why1.message == 1):
|
||||
stdout_string = sys.stdout.getvalue()
|
||||
stderr_string = sys.stderr.getvalue()
|
||||
else:
|
||||
raise why1
|
||||
except Exception, why:
|
||||
raise why
|
||||
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
if stdout_string is None:
|
||||
raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0]))
|
||||
return stdout_string, stderr_string
|
||||
|
||||
|
||||
def get_file_info(dirpath):
|
||||
filelist = os.listdir(dirpath)
|
||||
print 'Files found: {}'.format(filelist)
|
||||
numfiles = len(filelist)
|
||||
if numfiles == 1:
|
||||
filepath = os.path.join(dirpath, filelist[0])
|
||||
with open(filepath, 'r') as cddfile:
|
||||
filecontents = cddfile.readlines()
|
||||
numlines = len(filecontents)
|
||||
return filepath, numlines
|
||||
else:
|
||||
raise Exception("Expected to find a single file in {}, but found {}".format(dirpath, filelist))
|
||||
|
||||
|
||||
class PearsonTestCase(TestCase):
|
||||
'''
|
||||
Base class for tests running Pearson-related commands
|
||||
'''
|
||||
|
||||
def assertErrorContains(self, error_message, expected):
|
||||
self.assertTrue(error_message.find(expected) >= 0, 'error message "{}" did not contain "{}"'.format(error_message, expected))
|
||||
|
||||
def setUp(self):
|
||||
self.import_dir = mkdtemp(prefix="import")
|
||||
self.addCleanup(shutil.rmtree, self.import_dir)
|
||||
self.export_dir = mkdtemp(prefix="export")
|
||||
self.addCleanup(shutil.rmtree, self.export_dir)
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
# and clean up the database:
|
||||
# TestCenterUser.objects.all().delete()
|
||||
# TestCenterRegistration.objects.all().delete()
|
||||
|
||||
|
||||
class PearsonCommandTestCase(PearsonTestCase):
|
||||
|
||||
def test_missing_demographic_fields(self):
|
||||
# We won't bother to test all details of form validation here.
|
||||
# It is enough to show that it works here, but deal with test cases for the form
|
||||
# validation in the student tests, not these management tests.
|
||||
username = 'baduser'
|
||||
User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass')
|
||||
options = {}
|
||||
error_string = get_command_error_text('pearson_make_tc_user', username, **options)
|
||||
self.assertTrue(error_string.find('Field Form errors encountered:') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: city') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: first_name') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: last_name') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: country') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: phone_country_code') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: phone') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: address_1') >= 0)
|
||||
self.assertErrorContains(error_string, 'Field Form Error: address_1')
|
||||
|
||||
def test_create_good_testcenter_user(self):
|
||||
testcenter_user = create_tc_user("test_good_user")
|
||||
self.assertIsNotNone(testcenter_user)
|
||||
|
||||
def test_create_good_testcenter_registration(self):
|
||||
username = 'test_good_registration'
|
||||
create_tc_user(username)
|
||||
registration = create_tc_registration(username)
|
||||
self.assertIsNotNone(registration)
|
||||
|
||||
def test_cdd_missing_option(self):
|
||||
error_string = get_command_error_text('pearson_export_cdd', **{})
|
||||
self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used')
|
||||
|
||||
def test_ead_missing_option(self):
|
||||
error_string = get_command_error_text('pearson_export_ead', **{})
|
||||
self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used')
|
||||
|
||||
def test_export_single_cdd(self):
|
||||
# before we generate any tc_users, we expect there to be nothing to output:
|
||||
options = {'dest-from-settings': True}
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}):
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# generating a tc_user should result in a line in the output
|
||||
username = 'test_single_cdd'
|
||||
create_tc_user(username)
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line")
|
||||
os.remove(filepath)
|
||||
|
||||
# output after registration should not have any entries again.
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# if we modify the record, then it should be output again:
|
||||
user_options = {'first_name': 'NewTestFirst', }
|
||||
call_command('pearson_make_tc_user', username, **user_options)
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line")
|
||||
os.remove(filepath)
|
||||
|
||||
def test_export_single_ead(self):
|
||||
# before we generate any registrations, we expect there to be nothing to output:
|
||||
options = {'dest-from-settings': True}
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}):
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# generating a registration should result in a line in the output
|
||||
username = 'test_single_ead'
|
||||
create_tc_user(username)
|
||||
create_tc_registration(username)
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 2, "Expect ead file to have one non-header line")
|
||||
os.remove(filepath)
|
||||
|
||||
# output after registration should not have any entries again.
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# if we modify the record, then it should be output again:
|
||||
create_tc_registration(username, accommodation_code='EQPMNT')
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 2, "Expect ead file to have one non-header line")
|
||||
os.remove(filepath)
|
||||
|
||||
def test_export_multiple(self):
|
||||
create_multiple_registrations("export")
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}):
|
||||
options = {'dest-from-settings': True}
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 5, "Expect cdd file to have four non-header lines: total was {}".format(numlines))
|
||||
os.remove(filepath)
|
||||
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 7, "Expect ead file to have six non-header lines: total was {}".format(numlines))
|
||||
os.remove(filepath)
|
||||
|
||||
|
||||
# def test_bad_demographic_option(self):
|
||||
# username = 'nonuser'
|
||||
# output_string, stderrmsg = get_error_string_for_management_call('pearson_make_tc_user', username, **{'--garbage' : None })
|
||||
# print stderrmsg
|
||||
# self.assertErrorContains(stderrmsg, 'Unexpected option')
|
||||
#
|
||||
# def test_missing_demographic_user(self):
|
||||
# username = 'nonuser'
|
||||
# output_string, error_string = get_error_string_for_management_call('pearson_make_tc_user', username, **{})
|
||||
# self.assertErrorContains(error_string, 'User matching query does not exist')
|
||||
|
||||
# credentials for a test SFTP site:
|
||||
SFTP_HOSTNAME = 'ec2-23-20-150-101.compute-1.amazonaws.com'
|
||||
SFTP_USERNAME = 'pearsontest'
|
||||
SFTP_PASSWORD = 'password goes here'
|
||||
|
||||
S3_BUCKET = 'edx-pearson-archive'
|
||||
AWS_ACCESS_KEY_ID = 'put yours here'
|
||||
AWS_SECRET_ACCESS_KEY = 'put yours here'
|
||||
|
||||
|
||||
class PearsonTransferTestCase(PearsonTestCase):
|
||||
'''
|
||||
Class for tests running Pearson transfers
|
||||
'''
|
||||
|
||||
def test_transfer_config(self):
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **{'mode': 'garbage'})
|
||||
self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries')
|
||||
|
||||
stderrmsg = get_command_error_text('pearson_transfer')
|
||||
self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries')
|
||||
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir,
|
||||
'LOCAL_IMPORT': self.import_dir}):
|
||||
stderrmsg = get_command_error_text('pearson_transfer')
|
||||
self.assertErrorContains(stderrmsg, 'Error: No entry in the PEARSON settings')
|
||||
|
||||
def test_transfer_export_missing_dest_dir(self):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations('export_missing_dest')
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir,
|
||||
'SFTP_EXPORT': 'this/does/not/exist',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'export'}
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **options)
|
||||
self.assertErrorContains(stderrmsg, 'Error: SFTP destination path does not exist')
|
||||
|
||||
def test_transfer_export(self):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations("transfer_export")
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir,
|
||||
'SFTP_EXPORT': 'results/topvue',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'export'}
|
||||
# call_command('pearson_transfer', **options)
|
||||
# # confirm that the export directory is still empty:
|
||||
# self.assertEqual(len(os.listdir(self.export_dir)), 0, "expected export directory to be empty")
|
||||
|
||||
def test_transfer_import_missing_source_dir(self):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations('import_missing_src')
|
||||
with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir,
|
||||
'SFTP_IMPORT': 'this/does/not/exist',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'import'}
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **options)
|
||||
self.assertErrorContains(stderrmsg, 'Error: SFTP source path does not exist')
|
||||
|
||||
def test_transfer_import(self):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations('import_missing_src')
|
||||
with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir,
|
||||
'SFTP_IMPORT': 'results',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'import'}
|
||||
call_command('pearson_transfer', **options)
|
||||
self.assertEqual(len(os.listdir(self.import_dir)), 0, "expected import directory to be empty")
|
||||
185
common/djangoapps/student/migrations/0029_remove_pearson.py
Normal file
185
common/djangoapps/student/migrations/0029_remove_pearson.py
Normal file
@@ -0,0 +1,185 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Deleting model 'TestCenterUser'
|
||||
db.delete_table('student_testcenteruser')
|
||||
|
||||
# Deleting model 'TestCenterRegistration'
|
||||
db.delete_table('student_testcenterregistration')
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Adding model 'TestCenterUser'
|
||||
db.create_table('student_testcenteruser', (
|
||||
('last_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
|
||||
('suffix', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)),
|
||||
('confirmed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True, db_index=True)),
|
||||
('salutation', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)),
|
||||
('postal_code', self.gf('django.db.models.fields.CharField')(blank=True, max_length=16, db_index=True)),
|
||||
('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('city', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)),
|
||||
('first_name', self.gf('django.db.models.fields.CharField')(max_length=30, db_index=True)),
|
||||
('middle_name', self.gf('django.db.models.fields.CharField')(max_length=30, blank=True)),
|
||||
('phone_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)),
|
||||
('upload_status', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)),
|
||||
('state', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)),
|
||||
('upload_error_message', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)),
|
||||
('company_name', self.gf('django.db.models.fields.CharField')(blank=True, max_length=50, db_index=True)),
|
||||
('candidate_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)),
|
||||
('fax', self.gf('django.db.models.fields.CharField')(max_length=35, blank=True)),
|
||||
('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
|
||||
('phone', self.gf('django.db.models.fields.CharField')(max_length=35)),
|
||||
('user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['auth.User'], unique=True)),
|
||||
('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(blank=True, null=True, db_index=True)),
|
||||
('extension', self.gf('django.db.models.fields.CharField')(blank=True, max_length=8, db_index=True)),
|
||||
('fax_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, blank=True)),
|
||||
('country', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)),
|
||||
('client_candidate_id', self.gf('django.db.models.fields.CharField')(max_length=50, unique=True, db_index=True)),
|
||||
('address_1', self.gf('django.db.models.fields.CharField')(max_length=40)),
|
||||
('address_2', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)),
|
||||
('address_3', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)),
|
||||
('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True, db_index=True)),
|
||||
))
|
||||
db.send_create_signal('student', ['TestCenterUser'])
|
||||
|
||||
# Adding model 'TestCenterRegistration'
|
||||
db.create_table('student_testcenterregistration', (
|
||||
('client_authorization_id', self.gf('django.db.models.fields.CharField')(max_length=20, unique=True, db_index=True)),
|
||||
('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
|
||||
('authorization_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)),
|
||||
('upload_status', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)),
|
||||
('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True, db_index=True)),
|
||||
('confirmed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True, db_index=True)),
|
||||
('accommodation_request', self.gf('django.db.models.fields.CharField')(max_length=1024, blank=True)),
|
||||
('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)),
|
||||
('exam_series_code', self.gf('django.db.models.fields.CharField')(max_length=15, db_index=True)),
|
||||
('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('upload_error_message', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)),
|
||||
('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)),
|
||||
('testcenter_user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['student.TestCenterUser'])),
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)),
|
||||
))
|
||||
db.send_create_signal('student', ['TestCenterRegistration'])
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'student.courseenrollment': {
|
||||
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.courseenrollmentallowed': {
|
||||
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
|
||||
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'student.pendingemailchange': {
|
||||
'Meta': {'object_name': 'PendingEmailChange'},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.pendingnamechange': {
|
||||
'Meta': {'object_name': 'PendingNameChange'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.registration': {
|
||||
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.userprofile': {
|
||||
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
|
||||
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
|
||||
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
|
||||
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'student.userstanding': {
|
||||
'Meta': {'object_name': 'UserStanding'},
|
||||
'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.usertestgroup': {
|
||||
'Meta': {'object_name': 'UserTestGroup'},
|
||||
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import DataMigration
|
||||
from django.db import models
|
||||
|
||||
class Migration(DataMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
"Write your forwards methods here."
|
||||
# Note: Remember to use orm['appname.ModelName'] rather than "from appname.models..."
|
||||
db.execute("DROP TABLE student_anonymoususerid_temp_archive")
|
||||
|
||||
def backwards(self, orm):
|
||||
"Write your backwards methods here."
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'student.anonymoususerid': {
|
||||
'Meta': {'object_name': 'AnonymousUserId'},
|
||||
'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.courseenrollment': {
|
||||
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.courseenrollmentallowed': {
|
||||
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
|
||||
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'student.pendingemailchange': {
|
||||
'Meta': {'object_name': 'PendingEmailChange'},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.pendingnamechange': {
|
||||
'Meta': {'object_name': 'PendingNameChange'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.registration': {
|
||||
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.testcenteruser': {
|
||||
'Meta': {'object_name': 'TestCenterUser'},
|
||||
'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
|
||||
'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
|
||||
'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
|
||||
'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
|
||||
'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
|
||||
'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.userprofile': {
|
||||
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
|
||||
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
|
||||
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
|
||||
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'student.userstanding': {
|
||||
'Meta': {'object_name': 'UserStanding'},
|
||||
'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.usertestgroup': {
|
||||
'Meta': {'object_name': 'UserTestGroup'},
|
||||
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
symmetrical = True
|
||||
@@ -11,7 +11,6 @@ file and check it in at the same time as your model changes. To do that,
|
||||
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
|
||||
"""
|
||||
from datetime import datetime
|
||||
from random import randint
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -22,7 +21,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||
from django.db import models, IntegrityError
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.dispatch import receiver, Signal
|
||||
import django.dispatch
|
||||
from django.forms import ModelForm, forms
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
@@ -37,7 +36,7 @@ from track.views import server_track
|
||||
from eventtracking import tracker
|
||||
|
||||
|
||||
unenroll_done = django.dispatch.Signal(providing_args=["course_enrollment"])
|
||||
unenroll_done = Signal(providing_args=["course_enrollment"])
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
AUDIT_LOG = logging.getLogger("audit")
|
||||
@@ -220,480 +219,6 @@ class UserProfile(models.Model):
|
||||
def set_meta(self, js):
|
||||
self.meta = json.dumps(js)
|
||||
|
||||
TEST_CENTER_STATUS_ACCEPTED = "Accepted"
|
||||
TEST_CENTER_STATUS_ERROR = "Error"
|
||||
|
||||
|
||||
class TestCenterUser(models.Model):
|
||||
"""This is our representation of the User for in-person testing, and
|
||||
specifically for Pearson at this point. A few things to note:
|
||||
|
||||
* Pearson only supports Latin-1, so we have to make sure that the data we
|
||||
capture here will work with that encoding.
|
||||
* While we have a lot of this demographic data in UserProfile, it's much
|
||||
more free-structured there. We'll try to pre-pop the form with data from
|
||||
UserProfile, but we'll need to have a step where people who are signing
|
||||
up re-enter their demographic data into the fields we specify.
|
||||
* Users are only created here if they register to take an exam in person.
|
||||
|
||||
The field names and lengths are modeled on the conventions and constraints
|
||||
of Pearson's data import system, including oddities such as suffix having
|
||||
a limit of 255 while last_name only gets 50.
|
||||
|
||||
Also storing here the confirmation information received from Pearson (if any)
|
||||
as to the success or failure of the upload. (VCDC file)
|
||||
"""
|
||||
# Our own record keeping...
|
||||
user = models.ForeignKey(User, unique=True, default=None)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, db_index=True)
|
||||
# user_updated_at happens only when the user makes a change to their data,
|
||||
# and is something Pearson needs to know to manage updates. Unlike
|
||||
# updated_at, this will not get incremented when we do a batch data import.
|
||||
user_updated_at = models.DateTimeField(db_index=True)
|
||||
|
||||
# Unique ID we assign our user for the Test Center.
|
||||
client_candidate_id = models.CharField(unique=True, max_length=50, db_index=True)
|
||||
|
||||
# Name
|
||||
first_name = models.CharField(max_length=30, db_index=True)
|
||||
last_name = models.CharField(max_length=50, db_index=True)
|
||||
middle_name = models.CharField(max_length=30, blank=True)
|
||||
suffix = models.CharField(max_length=255, blank=True)
|
||||
salutation = models.CharField(max_length=50, blank=True)
|
||||
|
||||
# Address
|
||||
address_1 = models.CharField(max_length=40)
|
||||
address_2 = models.CharField(max_length=40, blank=True)
|
||||
address_3 = models.CharField(max_length=40, blank=True)
|
||||
city = models.CharField(max_length=32, db_index=True)
|
||||
# state example: HI -- they have an acceptable list that we'll just plug in
|
||||
# state is required if you're in the US or Canada, but otherwise not.
|
||||
state = models.CharField(max_length=20, blank=True, db_index=True)
|
||||
# postal_code required if you're in the US or Canada
|
||||
postal_code = models.CharField(max_length=16, blank=True, db_index=True)
|
||||
# country is a ISO 3166-1 alpha-3 country code (e.g. "USA", "CAN", "MNG")
|
||||
country = models.CharField(max_length=3, db_index=True)
|
||||
|
||||
# Phone
|
||||
phone = models.CharField(max_length=35)
|
||||
extension = models.CharField(max_length=8, blank=True, db_index=True)
|
||||
phone_country_code = models.CharField(max_length=3, db_index=True)
|
||||
fax = models.CharField(max_length=35, blank=True)
|
||||
# fax_country_code required *if* fax is present.
|
||||
fax_country_code = models.CharField(max_length=3, blank=True)
|
||||
|
||||
# Company
|
||||
company_name = models.CharField(max_length=50, blank=True, db_index=True)
|
||||
|
||||
# time at which edX sent the registration to the test center
|
||||
uploaded_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
|
||||
# confirmation back from the test center, as well as timestamps
|
||||
# on when they processed the request, and when we received
|
||||
# confirmation back.
|
||||
processed_at = models.DateTimeField(null=True, db_index=True)
|
||||
upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted'
|
||||
upload_error_message = models.CharField(max_length=512, blank=True)
|
||||
# Unique ID given to us for this User by the Testing Center. It's null when
|
||||
# we first create the User entry, and may be assigned by Pearson later.
|
||||
# (However, it may never be set if we are always initiating such candidate creation.)
|
||||
candidate_id = models.IntegerField(null=True, db_index=True)
|
||||
confirmed_at = models.DateTimeField(null=True, db_index=True)
|
||||
|
||||
@property
|
||||
def needs_uploading(self):
|
||||
return self.uploaded_at is None or self.uploaded_at < self.user_updated_at
|
||||
|
||||
@staticmethod
|
||||
def user_provided_fields():
|
||||
return ['first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
|
||||
'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country',
|
||||
'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name']
|
||||
|
||||
@property
|
||||
def email(self):
|
||||
return self.user.email
|
||||
|
||||
def needs_update(self, fields):
|
||||
for fieldname in TestCenterUser.user_provided_fields():
|
||||
if fieldname in fields and getattr(self, fieldname) != fields[fieldname]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _generate_edx_id(prefix):
|
||||
NUM_DIGITS = 12
|
||||
return u"{}{:012}".format(prefix, randint(1, 10 ** NUM_DIGITS - 1))
|
||||
|
||||
@staticmethod
|
||||
def _generate_candidate_id():
|
||||
return TestCenterUser._generate_edx_id("edX")
|
||||
|
||||
@classmethod
|
||||
def create(cls, user):
|
||||
testcenter_user = cls(user=user)
|
||||
# testcenter_user.candidate_id remains unset
|
||||
# assign an ID of our own:
|
||||
cand_id = cls._generate_candidate_id()
|
||||
while TestCenterUser.objects.filter(client_candidate_id=cand_id).exists():
|
||||
cand_id = cls._generate_candidate_id()
|
||||
testcenter_user.client_candidate_id = cand_id
|
||||
return testcenter_user
|
||||
|
||||
@property
|
||||
def is_accepted(self):
|
||||
return self.upload_status == TEST_CENTER_STATUS_ACCEPTED
|
||||
|
||||
@property
|
||||
def is_rejected(self):
|
||||
return self.upload_status == TEST_CENTER_STATUS_ERROR
|
||||
|
||||
@property
|
||||
def is_pending(self):
|
||||
return not self.is_accepted and not self.is_rejected
|
||||
|
||||
|
||||
class TestCenterUserForm(ModelForm):
|
||||
class Meta:
|
||||
model = TestCenterUser
|
||||
fields = ('first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
|
||||
'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country',
|
||||
'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name')
|
||||
|
||||
def update_and_save(self):
|
||||
new_user = self.save(commit=False)
|
||||
# create additional values here:
|
||||
new_user.user_updated_at = datetime.now(UTC)
|
||||
new_user.upload_status = ''
|
||||
new_user.save()
|
||||
log.info("Updated demographic information for user's test center exam registration: username \"{}\" ".format(new_user.user.username))
|
||||
|
||||
# add validation:
|
||||
|
||||
def clean_country(self):
|
||||
code = self.cleaned_data['country']
|
||||
if code and (len(code) != 3 or not code.isalpha()):
|
||||
raise forms.ValidationError(u'Must be three characters (ISO 3166-1): e.g. USA, CAN, MNG')
|
||||
return code.upper()
|
||||
|
||||
def clean(self):
|
||||
def _can_encode_as_latin(fieldvalue):
|
||||
try:
|
||||
fieldvalue.encode('iso-8859-1')
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
cleaned_data = super(TestCenterUserForm, self).clean()
|
||||
|
||||
# check for interactions between fields:
|
||||
if 'country' in cleaned_data:
|
||||
country = cleaned_data.get('country')
|
||||
if country == 'USA' or country == 'CAN':
|
||||
if 'state' in cleaned_data and len(cleaned_data['state']) == 0:
|
||||
self._errors['state'] = self.error_class([u'Required if country is USA or CAN.'])
|
||||
del cleaned_data['state']
|
||||
|
||||
if 'postal_code' in cleaned_data and len(cleaned_data['postal_code']) == 0:
|
||||
self._errors['postal_code'] = self.error_class([u'Required if country is USA or CAN.'])
|
||||
del cleaned_data['postal_code']
|
||||
|
||||
if 'fax' in cleaned_data and len(cleaned_data['fax']) > 0 and 'fax_country_code' in cleaned_data and len(cleaned_data['fax_country_code']) == 0:
|
||||
self._errors['fax_country_code'] = self.error_class([u'Required if fax is specified.'])
|
||||
del cleaned_data['fax_country_code']
|
||||
|
||||
# check encoding for all fields:
|
||||
cleaned_data_fields = [fieldname for fieldname in cleaned_data]
|
||||
for fieldname in cleaned_data_fields:
|
||||
if not _can_encode_as_latin(cleaned_data[fieldname]):
|
||||
self._errors[fieldname] = self.error_class([u'Must only use characters in Latin-1 (iso-8859-1) encoding'])
|
||||
del cleaned_data[fieldname]
|
||||
|
||||
# Always return the full collection of cleaned data.
|
||||
return cleaned_data
|
||||
|
||||
# our own code to indicate that a request has been rejected.
|
||||
ACCOMMODATION_REJECTED_CODE = 'NONE'
|
||||
|
||||
ACCOMMODATION_CODES = (
|
||||
(ACCOMMODATION_REJECTED_CODE, 'No Accommodation Granted'),
|
||||
('EQPMNT', 'Equipment'),
|
||||
('ET12ET', 'Extra Time - 1/2 Exam Time'),
|
||||
('ET30MN', 'Extra Time - 30 Minutes'),
|
||||
('ETDBTM', 'Extra Time - Double Time'),
|
||||
('SEPRMM', 'Separate Room'),
|
||||
('SRREAD', 'Separate Room and Reader'),
|
||||
('SRRERC', 'Separate Room and Reader/Recorder'),
|
||||
('SRRECR', 'Separate Room and Recorder'),
|
||||
('SRSEAN', 'Separate Room and Service Animal'),
|
||||
('SRSGNR', 'Separate Room and Sign Language Interpreter'),
|
||||
)
|
||||
|
||||
ACCOMMODATION_CODE_DICT = {code: name for (code, name) in ACCOMMODATION_CODES}
|
||||
|
||||
|
||||
class TestCenterRegistration(models.Model):
|
||||
"""
|
||||
This is our representation of a user's registration for in-person testing,
|
||||
and specifically for Pearson at this point. A few things to note:
|
||||
|
||||
* Pearson only supports Latin-1, so we have to make sure that the data we
|
||||
capture here will work with that encoding. This is less of an issue
|
||||
than for the TestCenterUser.
|
||||
* Registrations are only created here when a user registers to take an exam in person.
|
||||
|
||||
The field names and lengths are modeled on the conventions and constraints
|
||||
of Pearson's data import system.
|
||||
"""
|
||||
# to find an exam registration, we key off of the user and course_id.
|
||||
# If multiple exams per course are possible, we would also need to add the
|
||||
# exam_series_code.
|
||||
testcenter_user = models.ForeignKey(TestCenterUser, default=None)
|
||||
course_id = models.CharField(max_length=128, db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, db_index=True)
|
||||
# user_updated_at happens only when the user makes a change to their data,
|
||||
# and is something Pearson needs to know to manage updates. Unlike
|
||||
# updated_at, this will not get incremented when we do a batch data import.
|
||||
# The appointment dates, the exam count, and the accommodation codes can be updated,
|
||||
# but hopefully this won't happen often.
|
||||
user_updated_at = models.DateTimeField(db_index=True)
|
||||
# "client_authorization_id" is our unique identifier for the authorization.
|
||||
# This must be present for an update or delete to be sent to Pearson.
|
||||
client_authorization_id = models.CharField(max_length=20, unique=True, db_index=True)
|
||||
|
||||
# information about the test, from the course policy:
|
||||
exam_series_code = models.CharField(max_length=15, db_index=True)
|
||||
eligibility_appointment_date_first = models.DateField(db_index=True)
|
||||
eligibility_appointment_date_last = models.DateField(db_index=True)
|
||||
|
||||
# this is really a list of codes, using an '*' as a delimiter.
|
||||
# So it's not a choice list. We use the special value of ACCOMMODATION_REJECTED_CODE
|
||||
# to indicate the rejection of an accommodation request.
|
||||
accommodation_code = models.CharField(max_length=64, blank=True)
|
||||
|
||||
# store the original text of the accommodation request.
|
||||
accommodation_request = models.CharField(max_length=1024, blank=True, db_index=False)
|
||||
|
||||
# time at which edX sent the registration to the test center
|
||||
uploaded_at = models.DateTimeField(null=True, db_index=True)
|
||||
|
||||
# confirmation back from the test center, as well as timestamps
|
||||
# on when they processed the request, and when we received
|
||||
# confirmation back.
|
||||
processed_at = models.DateTimeField(null=True, db_index=True)
|
||||
upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted'
|
||||
upload_error_message = models.CharField(max_length=512, blank=True)
|
||||
# Unique ID given to us for this registration by the Testing Center. It's null when
|
||||
# we first create the registration entry, and may be assigned by Pearson later.
|
||||
# (However, it may never be set if we are always initiating such candidate creation.)
|
||||
authorization_id = models.IntegerField(null=True, db_index=True)
|
||||
confirmed_at = models.DateTimeField(null=True, db_index=True)
|
||||
|
||||
@property
|
||||
def candidate_id(self):
|
||||
return self.testcenter_user.candidate_id
|
||||
|
||||
@property
|
||||
def client_candidate_id(self):
|
||||
return self.testcenter_user.client_candidate_id
|
||||
|
||||
@property
|
||||
def authorization_transaction_type(self):
|
||||
if self.authorization_id is not None:
|
||||
return 'Update'
|
||||
elif self.uploaded_at is None:
|
||||
return 'Add'
|
||||
elif self.registration_is_rejected:
|
||||
# Assume that if the registration was rejected before,
|
||||
# it is more likely this is the (first) correction
|
||||
# than a second correction in flight before the first was
|
||||
# processed.
|
||||
return 'Add'
|
||||
else:
|
||||
# TODO: decide what to send when we have uploaded an initial version,
|
||||
# but have not received confirmation back from that upload. If the
|
||||
# registration here has been changed, then we don't know if this changed
|
||||
# registration should be submitted as an 'add' or an 'update'.
|
||||
#
|
||||
# If the first registration were lost or in error (e.g. bad code),
|
||||
# the second should be an "Add". If the first were processed successfully,
|
||||
# then the second should be an "Update". We just don't know....
|
||||
return 'Update'
|
||||
|
||||
@property
|
||||
def exam_authorization_count(self):
|
||||
# Someday this could go in the database (with a default value). But at present,
|
||||
# we do not expect anyone to be authorized to take an exam more than once.
|
||||
return 1
|
||||
|
||||
@property
|
||||
def needs_uploading(self):
|
||||
return self.uploaded_at is None or self.uploaded_at < self.user_updated_at
|
||||
|
||||
@classmethod
|
||||
def create(cls, testcenter_user, exam, accommodation_request):
|
||||
registration = cls(testcenter_user=testcenter_user)
|
||||
registration.course_id = exam.course_id
|
||||
registration.accommodation_request = accommodation_request.strip()
|
||||
registration.exam_series_code = exam.exam_series_code
|
||||
registration.eligibility_appointment_date_first = exam.first_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
registration.eligibility_appointment_date_last = exam.last_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
registration.client_authorization_id = cls._create_client_authorization_id()
|
||||
# accommodation_code remains blank for now, along with Pearson confirmation information
|
||||
return registration
|
||||
|
||||
@staticmethod
|
||||
def _generate_authorization_id():
|
||||
return TestCenterUser._generate_edx_id("edXexam")
|
||||
|
||||
@staticmethod
|
||||
def _create_client_authorization_id():
|
||||
"""
|
||||
Return a unique id for a registration, suitable for using as an authorization code
|
||||
for Pearson. It must fit within 20 characters.
|
||||
"""
|
||||
# generate a random value, and check to see if it already is in use here
|
||||
auth_id = TestCenterRegistration._generate_authorization_id()
|
||||
while TestCenterRegistration.objects.filter(client_authorization_id=auth_id).exists():
|
||||
auth_id = TestCenterRegistration._generate_authorization_id()
|
||||
return auth_id
|
||||
|
||||
# methods for providing registration status details on registration page:
|
||||
@property
|
||||
def demographics_is_accepted(self):
|
||||
return self.testcenter_user.is_accepted
|
||||
|
||||
@property
|
||||
def demographics_is_rejected(self):
|
||||
return self.testcenter_user.is_rejected
|
||||
|
||||
@property
|
||||
def demographics_is_pending(self):
|
||||
return self.testcenter_user.is_pending
|
||||
|
||||
@property
|
||||
def accommodation_is_accepted(self):
|
||||
return len(self.accommodation_request) > 0 and len(self.accommodation_code) > 0 and self.accommodation_code != ACCOMMODATION_REJECTED_CODE
|
||||
|
||||
@property
|
||||
def accommodation_is_rejected(self):
|
||||
return len(self.accommodation_request) > 0 and self.accommodation_code == ACCOMMODATION_REJECTED_CODE
|
||||
|
||||
@property
|
||||
def accommodation_is_pending(self):
|
||||
return len(self.accommodation_request) > 0 and len(self.accommodation_code) == 0
|
||||
|
||||
@property
|
||||
def accommodation_is_skipped(self):
|
||||
return len(self.accommodation_request) == 0
|
||||
|
||||
@property
|
||||
def registration_is_accepted(self):
|
||||
return self.upload_status == TEST_CENTER_STATUS_ACCEPTED
|
||||
|
||||
@property
|
||||
def registration_is_rejected(self):
|
||||
return self.upload_status == TEST_CENTER_STATUS_ERROR
|
||||
|
||||
@property
|
||||
def registration_is_pending(self):
|
||||
return not self.registration_is_accepted and not self.registration_is_rejected
|
||||
|
||||
# methods for providing registration status summary on dashboard page:
|
||||
@property
|
||||
def is_accepted(self):
|
||||
return self.registration_is_accepted and self.demographics_is_accepted
|
||||
|
||||
@property
|
||||
def is_rejected(self):
|
||||
return self.registration_is_rejected or self.demographics_is_rejected
|
||||
|
||||
@property
|
||||
def is_pending(self):
|
||||
return not self.is_accepted and not self.is_rejected
|
||||
|
||||
def get_accommodation_codes(self):
|
||||
return self.accommodation_code.split('*')
|
||||
|
||||
def get_accommodation_names(self):
|
||||
return [ACCOMMODATION_CODE_DICT.get(code, "Unknown code " + code) for code in self.get_accommodation_codes()]
|
||||
|
||||
@property
|
||||
def registration_signup_url(self):
|
||||
return settings.PEARSONVUE_SIGNINPAGE_URL
|
||||
|
||||
def demographics_status(self):
|
||||
if self.demographics_is_accepted:
|
||||
return "Accepted"
|
||||
elif self.demographics_is_rejected:
|
||||
return "Rejected"
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
def accommodation_status(self):
|
||||
if self.accommodation_is_skipped:
|
||||
return "Skipped"
|
||||
elif self.accommodation_is_accepted:
|
||||
return "Accepted"
|
||||
elif self.accommodation_is_rejected:
|
||||
return "Rejected"
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
def registration_status(self):
|
||||
if self.registration_is_accepted:
|
||||
return "Accepted"
|
||||
elif self.registration_is_rejected:
|
||||
return "Rejected"
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
|
||||
class TestCenterRegistrationForm(ModelForm):
|
||||
class Meta:
|
||||
model = TestCenterRegistration
|
||||
fields = ('accommodation_request', 'accommodation_code')
|
||||
|
||||
def clean_accommodation_request(self):
|
||||
code = self.cleaned_data['accommodation_request']
|
||||
if code and len(code) > 0:
|
||||
return code.strip()
|
||||
return code
|
||||
|
||||
def update_and_save(self):
|
||||
registration = self.save(commit=False)
|
||||
# create additional values here:
|
||||
registration.user_updated_at = datetime.now(UTC)
|
||||
registration.upload_status = ''
|
||||
registration.save()
|
||||
log.info("Updated registration information for user's test center exam registration: username \"{}\" course \"{}\", examcode \"{}\"".format(registration.testcenter_user.user.username, registration.course_id, registration.exam_series_code))
|
||||
|
||||
def clean_accommodation_code(self):
|
||||
code = self.cleaned_data['accommodation_code']
|
||||
if code:
|
||||
code = code.upper()
|
||||
codes = code.split('*')
|
||||
for codeval in codes:
|
||||
if codeval not in ACCOMMODATION_CODE_DICT:
|
||||
raise forms.ValidationError(u'Invalid accommodation code specified: "{}"'.format(codeval))
|
||||
return code
|
||||
|
||||
|
||||
def get_testcenter_registration(user, course_id, exam_series_code):
|
||||
try:
|
||||
tcu = TestCenterUser.objects.get(user=user)
|
||||
except TestCenterUser.DoesNotExist:
|
||||
return []
|
||||
return TestCenterRegistration.objects.filter(testcenter_user=tcu, course_id=course_id, exam_series_code=exam_series_code)
|
||||
|
||||
# nosetests thinks that anything with _test_ in the name is a test.
|
||||
# Correct this (https://nose.readthedocs.org/en/latest/finding_tests.html)
|
||||
get_testcenter_registration.__test__ = False
|
||||
|
||||
|
||||
def unique_id_for_user(user):
|
||||
"""
|
||||
@@ -898,7 +423,7 @@ class CourseEnrollment(models.Model):
|
||||
verified the user authentication and access.
|
||||
"""
|
||||
enrollment = cls.get_or_create_enrollment(user, course_id)
|
||||
enrollment.update_enrollment(is_active=True)
|
||||
enrollment.update_enrollment(is_active=True, mode=mode)
|
||||
return enrollment
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -59,23 +59,28 @@ class ResetPasswordTests(TestCase):
|
||||
self.user_bad_passwd.password = UNUSABLE_PASSWORD
|
||||
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):
|
||||
"""Tests password reset behavior for user with password marked UNUSABLE_PASSWORD"""
|
||||
|
||||
bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email})
|
||||
bad_pwd_resp = password_reset(bad_pwd_req)
|
||||
# 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.content, json.dumps({'success': False,
|
||||
'error': 'Invalid e-mail or user'}))
|
||||
self.assertEquals(bad_pwd_resp.content, json.dumps({'success': True,
|
||||
'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):
|
||||
"""Now test the exception cases with of reset_password called with invalid email."""
|
||||
|
||||
bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"})
|
||||
bad_email_resp = password_reset(bad_email_req)
|
||||
# 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.content, json.dumps({'success': False,
|
||||
'error': 'Invalid e-mail or user'}))
|
||||
self.assertEquals(bad_email_resp.content, json.dumps({'success': True,
|
||||
'value': "('registration/password_reset_done.html', [])"}))
|
||||
|
||||
@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.
|
||||
@@ -152,38 +157,43 @@ class CourseEndingTest(TestCase):
|
||||
{'status': 'processing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False, })
|
||||
'show_survey_button': False,
|
||||
})
|
||||
|
||||
cert_status = {'status': 'unavailable'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'processing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False})
|
||||
|
||||
cert_status = {'status': 'generating', 'grade': '67'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'generating',
|
||||
'show_disabled_download_button': True,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
'show_survey_button': False,
|
||||
'mode': None
|
||||
})
|
||||
|
||||
cert_status = {'status': 'regenerating', 'grade': '67'}
|
||||
cert_status = {'status': 'generating', 'grade': '67', 'mode': 'honor'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'generating',
|
||||
'show_disabled_download_button': True,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
'grade': '67',
|
||||
'mode': 'honor'
|
||||
})
|
||||
|
||||
cert_status = {'status': 'regenerating', 'grade': '67', 'mode': 'verified'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'generating',
|
||||
'show_disabled_download_button': True,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67',
|
||||
'mode': 'verified'
|
||||
})
|
||||
|
||||
download_url = 'http://s3.edx/cert'
|
||||
cert_status = {'status': 'downloadable', 'grade': '67',
|
||||
'download_url': download_url}
|
||||
'download_url': download_url, 'mode': 'honor'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'ready',
|
||||
'show_disabled_download_button': False,
|
||||
@@ -191,30 +201,33 @@ class CourseEndingTest(TestCase):
|
||||
'download_url': download_url,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
'grade': '67',
|
||||
'mode': 'honor'
|
||||
})
|
||||
|
||||
cert_status = {'status': 'notpassing', 'grade': '67',
|
||||
'download_url': download_url}
|
||||
'download_url': download_url, 'mode': 'honor'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'notpassing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
'grade': '67',
|
||||
'mode': 'honor'
|
||||
})
|
||||
|
||||
# Test a course that doesn't have a survey specified
|
||||
course2 = Mock(end_of_course_survey_url=None)
|
||||
cert_status = {'status': 'notpassing', 'grade': '67',
|
||||
'download_url': download_url}
|
||||
'download_url': download_url, 'mode': 'honor'}
|
||||
self.assertEqual(_cert_info(user, course2, cert_status),
|
||||
{'status': 'notpassing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False,
|
||||
'grade': '67'
|
||||
'grade': '67',
|
||||
'mode': 'honor'
|
||||
})
|
||||
|
||||
|
||||
@@ -329,6 +342,14 @@ class EnrollInCourseTest(TestCase):
|
||||
)
|
||||
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):
|
||||
"""Ensures no events were emitted since the last event related assertion"""
|
||||
self.assertFalse(self.mock_server_track.called)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user