Merge remote-tracking branch 'origin/master' into fix/vik/oe-state
This commit is contained in:
@@ -5,12 +5,38 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
Studio: Allow course authors to set their course image on the schedule
|
||||
and details page, with support for JPEG and PNG images.
|
||||
|
||||
Blades: Took videoalpha out of alpha, replacing the old video player
|
||||
|
||||
Common: Allow instructors to input complicated expressions as answers to
|
||||
`NumericalResponse`s. Prior to the change only numbers were allowed, now any
|
||||
answer from '1/3' to 'sqrt(12)*(1-1/3^2+1/5/3^2)' are valid.
|
||||
|
||||
LMS: Enable beta instructor dashboard. The beta dashboard is a rearchitecture
|
||||
of the existing instructor dashboard and is available by clicking a link at
|
||||
the top right of the existing dashboard.
|
||||
|
||||
Common: CourseEnrollment has new fields `is_active` and `mode`. The mode will be
|
||||
used to differentiate different kinds of enrollments (currently, all enrollments
|
||||
are honor certificate enrollments). The `is_active` flag will be used to
|
||||
deactivate enrollments without deleting them, so that we know what course you
|
||||
*were* enrolled in. Because of the latter change, enrollment and unenrollment
|
||||
logic has been consolidated into the model -- you should use new class methods
|
||||
to `enroll()`, `unenroll()`, and to check `is_enrolled()`, instead of creating
|
||||
CourseEnrollment objects or querying them directly.
|
||||
|
||||
Studio: Email will be sent to admin address when a user requests course creator
|
||||
privileges for Studio (edge only).
|
||||
|
||||
Studio: Studio course authors (both instructors and staff) will be auto-enrolled
|
||||
for their courses so that "View Live" works.
|
||||
|
||||
Common: Add a new input type ``<formulaequationinput />`` for Formula/Numerical
|
||||
Responses. It periodically makes AJAX calls to preview and validate the
|
||||
student's input.
|
||||
|
||||
Common: Added ratelimiting to our authentication backend.
|
||||
|
||||
Common: Add additional logging to cover login attempts and logouts.
|
||||
@@ -77,6 +103,8 @@ LMS: Removed press releases
|
||||
|
||||
Common: Updated Sass and Bourbon libraries, added Neat library
|
||||
|
||||
LMS: Add a MixedModuleStore to aggregate the XMLModuleStore and MongoMonduleStore
|
||||
|
||||
LMS: Users are no longer auto-activated if they click "reset password"
|
||||
This is now done when they click on the link in the reset password
|
||||
email they receive (along with usual path through activation email).
|
||||
@@ -214,6 +242,12 @@ LMS: Fixed failing numeric response (decimal but no trailing digits).
|
||||
|
||||
LMS: XML Error module no longer shows students a stack trace.
|
||||
|
||||
Studio: Add feedback to end user if there is a problem exporting a course
|
||||
|
||||
Studio: Improve link re-writing on imports into a different course-id
|
||||
|
||||
Studio: Allow for intracourse linking in Capa Problems
|
||||
|
||||
Blades: Videoalpha.
|
||||
|
||||
XModules: Added partial credit for foldit module.
|
||||
@@ -222,6 +256,10 @@ XModules: Added "randomize" XModule to list of XModule types.
|
||||
|
||||
XModules: Show errors with full descriptors.
|
||||
|
||||
Studio: Add feedback to end user if there is a problem exporting a course
|
||||
|
||||
Studio: Improve link re-writing on imports into a different course-id
|
||||
|
||||
XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is
|
||||
dropped suddenly.
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ class CourseGroupTest(TestCase):
|
||||
create_all_course_groups(self.creator, self.location)
|
||||
add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)
|
||||
|
||||
location2 = 'i4x', 'mitX', '103', 'course2', 'test2'
|
||||
location2 = 'i4x', 'mitX', '103', 'course', 'test2'
|
||||
staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo')
|
||||
create_all_course_groups(self.creator, location2)
|
||||
add_user_to_course_group(self.creator, staff2, location2, STAFF_ROLE_NAME)
|
||||
@@ -193,7 +193,7 @@ class CourseGroupTest(TestCase):
|
||||
create_all_course_groups(self.creator, self.location)
|
||||
add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)
|
||||
|
||||
location2 = 'i4x', 'mitX', '103', 'course2', 'test2'
|
||||
location2 = 'i4x', 'mitX', '103', 'course', 'test2'
|
||||
creator2 = User.objects.create_user('testcreator2', 'testcreator2+courses@edx.org', 'foo')
|
||||
staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo')
|
||||
create_all_course_groups(creator2, location2)
|
||||
|
||||
@@ -2,6 +2,7 @@ Feature: Advanced (manual) course policy
|
||||
In order to specify course policy settings for which no custom user interface exists
|
||||
I want to be able to manually enter JSON key /value pairs
|
||||
|
||||
|
||||
Scenario: A course author sees default advanced settings
|
||||
Given I have opened a new course in Studio
|
||||
When I select the Advanced Settings
|
||||
@@ -11,6 +12,8 @@ Feature: Advanced (manual) course policy
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
Then the settings are alphabetized
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
Scenario: Test cancel editing key value
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the value of a policy key
|
||||
@@ -19,6 +22,8 @@ Feature: Advanced (manual) course policy
|
||||
And I reload the page
|
||||
Then the policy key value is unchanged
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
Scenario: Test editing key value
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the value of a policy key and save
|
||||
@@ -26,6 +31,8 @@ Feature: Advanced (manual) course policy
|
||||
And I reload the page
|
||||
Then the policy key value is changed
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
Scenario: Test how multi-line input appears
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a JSON object as a value for "discussion_topics"
|
||||
@@ -33,6 +40,8 @@ Feature: Advanced (manual) course policy
|
||||
And I reload the page
|
||||
Then it is displayed as formatted
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
Scenario: Test error if value supplied is of the wrong type
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a JSON object as a value for "display_name"
|
||||
@@ -41,6 +50,8 @@ Feature: Advanced (manual) course policy
|
||||
Then the policy key value is unchanged
|
||||
|
||||
# This feature will work in Firefox only when Firefox is the active window
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
Scenario: Test automatic quoting of non-JSON values
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a non-JSON value not in quotes
|
||||
@@ -48,6 +59,8 @@ Feature: Advanced (manual) course policy
|
||||
And I reload the page
|
||||
Then it is displayed as a string
|
||||
|
||||
# Sauce labs does not play nicely with CodeMirror
|
||||
@skip_sauce
|
||||
Scenario: Confirmation is shown on save
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the value of a policy key
|
||||
|
||||
@@ -10,7 +10,10 @@ Feature: Course checklists
|
||||
Then I can check and uncheck tasks in a checklist
|
||||
And They are correctly selected after reloading the page
|
||||
|
||||
# CHROME ONLY, due to issues getting link to be active in firefox
|
||||
# There are issues getting link to be active in browsers other than chrome
|
||||
@skip_firefox
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: A task can link to a location within Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to the course outline
|
||||
@@ -18,7 +21,10 @@ Feature: Course checklists
|
||||
And I press the browser back button
|
||||
Then I am brought back to the course outline in the correct state
|
||||
|
||||
# CHROME ONLY, due to issues getting link to be active in firefox
|
||||
# There are issues getting link to be active in browsers other than chrome
|
||||
@skip_firefox
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: A task can link to a location outside Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to help page
|
||||
|
||||
@@ -5,9 +5,11 @@ from lettuce import world, step
|
||||
from nose.tools import assert_true
|
||||
|
||||
from auth.authz import get_user_by_email, get_course_groupname_for_role
|
||||
from django.conf import settings
|
||||
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
import time
|
||||
import os
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from logging import getLogger
|
||||
@@ -15,6 +17,8 @@ logger = getLogger(__name__)
|
||||
|
||||
from terrain.browser import reset_data
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
########### STEP HELPERS ##############
|
||||
|
||||
|
||||
@@ -152,7 +156,8 @@ def log_into_studio(
|
||||
world.log_in(username=uname, password=password, email=email, name=name)
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
world.wait_for(lambda _driver: uname in world.css_find('h2.title')[0].text)
|
||||
|
||||
assert uname in world.css_text('h2.title', max_attempts=15)
|
||||
|
||||
def create_a_course():
|
||||
course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
@@ -210,27 +215,6 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
|
||||
time.sleep(float(1))
|
||||
|
||||
|
||||
@step('I have created a Video component$')
|
||||
def i_created_a_video_component(step):
|
||||
world.create_component_instance(
|
||||
step, '.large-video-icon',
|
||||
'video',
|
||||
'.xmodule_VideoModule',
|
||||
has_multiple_templates=False
|
||||
)
|
||||
|
||||
|
||||
@step('I have created a Video Alpha component$')
|
||||
def i_created_video_alpha(step):
|
||||
step.given('I have enabled the videoalpha advanced module')
|
||||
world.css_click('a.course-link')
|
||||
step.given('I have added a new subsection')
|
||||
step.given('I expand the first section')
|
||||
world.css_click('a.new-unit-item')
|
||||
world.css_click('.large-advanced-icon')
|
||||
world.click_component_from_menu('videoalpha', None, '.xmodule_VideoAlphaModule')
|
||||
|
||||
|
||||
@step('I have enabled the (.*) advanced module$')
|
||||
def i_enabled_the_advanced_module(step, module):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
@@ -248,16 +232,6 @@ def open_new_unit(step):
|
||||
world.css_click('a.new-unit-item')
|
||||
|
||||
|
||||
@step('when I view the (video.*) it (.*) show the captions')
|
||||
def shows_captions(_step, video_type, show_captions):
|
||||
# Prevent cookies from overriding course settings
|
||||
world.browser.cookies.delete('hide_captions')
|
||||
if show_captions == 'does not':
|
||||
assert world.css_has_class('.%s' % video_type, 'closed')
|
||||
else:
|
||||
assert world.is_css_not_present('.%s.closed' % video_type)
|
||||
|
||||
|
||||
@step('the save button is disabled$')
|
||||
def save_button_disabled(step):
|
||||
button_css = '.action-save'
|
||||
@@ -288,3 +262,12 @@ def type_in_codemirror(index, text):
|
||||
g._element.send_keys(text)
|
||||
if world.is_firefox():
|
||||
world.trigger_event('div.CodeMirror', index=index, event='blur')
|
||||
|
||||
|
||||
def upload_file(filename):
|
||||
file_css = '.upload-dialog input[type=file]'
|
||||
upload = world.css_find(file_css).first
|
||||
path = os.path.join(TEST_ROOT, filename)
|
||||
upload._element.send_keys(os.path.abspath(path))
|
||||
button_css = '.upload-dialog .action-upload'
|
||||
world.css_click(button_css)
|
||||
|
||||
@@ -64,6 +64,10 @@ Feature: Course Overview
|
||||
And I change an assignment's grading status
|
||||
Then I am shown a notification
|
||||
|
||||
# Notification is not shown on reorder for IE
|
||||
# Safari does not have moveMouseTo implemented
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: Notification is shown on subsection reorder
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
Feature: Course Settings
|
||||
As a course author, I want to be able to configure my course settings.
|
||||
|
||||
# Safari has trouble keeps dates on refresh
|
||||
@skip_safari
|
||||
Scenario: User can set course dates
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
@@ -8,12 +10,16 @@ Feature: Course Settings
|
||||
And I press the "Save" notification button
|
||||
Then I see the set dates on refresh
|
||||
|
||||
# IE has trouble with saving information
|
||||
@skip_internetexplorer
|
||||
Scenario: User can clear previously set course dates (except start date)
|
||||
Given I have set course dates
|
||||
And I clear all the dates except start
|
||||
And I press the "Save" notification button
|
||||
Then I see cleared dates on refresh
|
||||
|
||||
# IE has trouble with saving information
|
||||
@skip_internetexplorer
|
||||
Scenario: User cannot clear the course start date
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
@@ -21,6 +27,10 @@ Feature: Course Settings
|
||||
Then I receive a warning about course start date
|
||||
And The previously set start date is shown on refresh
|
||||
|
||||
# IE has trouble with saving information
|
||||
# Safari gets CSRF token errors
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: User can correct the course start date warning
|
||||
Given I have tried to clear the course start
|
||||
And I have entered a new course start date
|
||||
@@ -28,12 +38,16 @@ Feature: Course Settings
|
||||
Then The warning about course start date goes away
|
||||
And My new course start date is shown on refresh
|
||||
|
||||
# Safari does not save + refresh properly through sauce labs
|
||||
@skip_safari
|
||||
Scenario: Settings are only persisted when saved
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
When I change fields
|
||||
Then I do not see the new changes persisted on refresh
|
||||
|
||||
# Safari does not save + refresh properly through sauce labs
|
||||
@skip_safari
|
||||
Scenario: Settings are reset on cancel
|
||||
Given I have set course dates
|
||||
And I press the "Save" notification button
|
||||
@@ -41,6 +55,8 @@ Feature: Course Settings
|
||||
And I press the "Cancel" notification button
|
||||
Then I do not see the changes
|
||||
|
||||
# Safari gets CSRF token errors
|
||||
@skip_safari
|
||||
Scenario: Confirmation is shown on save
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
@@ -57,6 +73,7 @@ Feature: Course Settings
|
||||
| Course Start Time | 11:00 |
|
||||
| Course Introduction Video | 4r7wHMg5Yjg |
|
||||
| Course Effort | 200:00 |
|
||||
| Course Image URL | image.jpg |
|
||||
|
||||
# Special case because we have to type in code mirror
|
||||
Scenario: Changes in Course Overview show a confirmation
|
||||
@@ -71,3 +88,11 @@ Feature: Course Settings
|
||||
When I select Schedule and Details
|
||||
And I change the "Course Start Date" field to ""
|
||||
Then the save button is disabled
|
||||
|
||||
Scenario: User can upload course image
|
||||
Given I have opened a new course in Studio
|
||||
When I select Schedule and Details
|
||||
And I click the "Upload Course Image" button
|
||||
And I upload a new course image
|
||||
Then I should see the new course image
|
||||
And the image URL should be present in the field
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
from lettuce import world, step
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from common import type_in_codemirror
|
||||
from common import type_in_codemirror, upload_file
|
||||
from django.conf import settings
|
||||
|
||||
from nose.tools import assert_true, assert_false, assert_equal
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
COURSE_START_DATE_CSS = "#course-start-date"
|
||||
COURSE_END_DATE_CSS = "#course-end-date"
|
||||
ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date"
|
||||
@@ -146,6 +149,35 @@ def test_change_course_overview(_step):
|
||||
type_in_codemirror(0, "<h1>Overview</h1>")
|
||||
|
||||
|
||||
@step('I click the "Upload Course Image" button')
|
||||
def click_upload_button(_step):
|
||||
button_css = '.action-upload-image'
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step('I upload a new course image$')
|
||||
def upload_new_course_image(_step):
|
||||
upload_file('image.jpg')
|
||||
|
||||
|
||||
@step('I should see the new course image$')
|
||||
def i_see_new_course_image(_step):
|
||||
img_css = '#course-image'
|
||||
images = world.css_find(img_css)
|
||||
assert len(images) == 1
|
||||
img = images[0]
|
||||
expected_src = '/c4x/MITx/999/asset/image.jpg'
|
||||
# Don't worry about the domain in the URL
|
||||
assert img['src'].endswith(expected_src)
|
||||
|
||||
|
||||
@step('the image URL should be present in the field')
|
||||
def image_url_present(_step):
|
||||
field_css = '#course-image-url'
|
||||
field = world.css_find(field_css).first
|
||||
expected_value = '/c4x/MITx/999/asset/image.jpg'
|
||||
assert field.value == expected_value
|
||||
|
||||
|
||||
############### HELPER METHODS ####################
|
||||
def set_date_or_time(css, date_or_time):
|
||||
|
||||
@@ -91,7 +91,7 @@ def remove_course_team_admin(_step, outer_capture, name):
|
||||
|
||||
@step(u'"([^"]*)" logs in$')
|
||||
def other_user_login(_step, name):
|
||||
world.browser.cookies.delete()
|
||||
world.visit('logout')
|
||||
world.visit('/')
|
||||
|
||||
signin_css = 'a.action-signin'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
Feature: Course updates
|
||||
As a course author, I want to be able to provide updates to my students
|
||||
|
||||
# Internet explorer can't select all so the update appears weirdly
|
||||
@skip_internetexplorer
|
||||
Scenario: Users can add updates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
@@ -8,6 +10,8 @@ Feature: Course updates
|
||||
Then I should see the update "Hello"
|
||||
And I see a "saving" notification
|
||||
|
||||
# Internet explorer can't select all so the update appears weirdly
|
||||
@skip_internetexplorer
|
||||
Scenario: Users can edit updates
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
@@ -33,6 +37,8 @@ Feature: Course updates
|
||||
Then I should see the date "June 1, 2013"
|
||||
And I see a "saving" notification
|
||||
|
||||
# Internet explorer can't select all so the update appears weirdly
|
||||
@skip_internetexplorer
|
||||
Scenario: Users can change handouts
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the course updates page
|
||||
|
||||
@@ -8,6 +8,6 @@ Feature: Create Course
|
||||
And I am logged into Studio
|
||||
When I click the New Course button
|
||||
And I fill in the new course information
|
||||
And I press the "Save" button
|
||||
And I press the "Create" button
|
||||
Then the Courseware page has loaded in Studio
|
||||
And I see a link for adding a new section
|
||||
|
||||
@@ -6,6 +6,8 @@ Feature: Discussion Component Editor
|
||||
And I edit and select Settings
|
||||
Then I see three alphabetized settings and their expected values
|
||||
|
||||
# Safari doesn't save the name properly
|
||||
@skip_safari
|
||||
Scenario: User can modify display name
|
||||
Given I have created a Discussion Tag
|
||||
And I edit and select Settings
|
||||
|
||||
@@ -13,7 +13,7 @@ Feature: Course Grading
|
||||
When I add "6" new grades
|
||||
Then I see I now have "5" grades
|
||||
|
||||
#Cannot reliably make the delete button appear so using javascript instead
|
||||
# Cannot reliably make the delete button appear so using javascript instead
|
||||
Scenario: Users can delete grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
@@ -21,6 +21,9 @@ Feature: Course Grading
|
||||
And I delete a grade
|
||||
Then I see I now have "2" grades
|
||||
|
||||
# IE and Safari cannot reliably drag and drop through selenium
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: Users can move grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
@@ -84,3 +87,39 @@ Feature: Course Grading
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to ""
|
||||
Then the save button is disabled
|
||||
|
||||
# IE and Safari cannot type in grade range name
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: User can edit grading range names
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change the highest grade range to "Good"
|
||||
And I press the "Save" notification button
|
||||
And I reload the page
|
||||
Then I see the highest grade range is "Good"
|
||||
|
||||
Scenario: User cannot edit failing grade range name
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
Then I cannot edit the "Fail" grade range
|
||||
|
||||
Scenario: User can set a grace period greater than one day
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change the grace period to "48:00"
|
||||
And I press the "Save" notification button
|
||||
And I reload the page
|
||||
Then I see the grace period is "48:00"
|
||||
|
||||
Scenario: Grace periods of more than 59 minutes are wrapped to the correct time
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change the grace period to "01:99"
|
||||
And I press the "Save" notification button
|
||||
And I reload the page
|
||||
Then I see the grace period is "02:39"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from terrain.steps import reload_the_page
|
||||
from selenium.common.exceptions import InvalidElementStateException
|
||||
|
||||
|
||||
@step(u'I am viewing the grading settings')
|
||||
@@ -111,10 +112,51 @@ def changes_not_persisted(step):
|
||||
|
||||
@step(u'I see the assignment type "(.*)"$')
|
||||
def i_see_the_assignment_type(_step, name):
|
||||
assignment_css = '#course-grading-assignment-name'
|
||||
assignments = world.css_find(assignment_css)
|
||||
types = [ele['value'] for ele in assignments]
|
||||
assert name in types
|
||||
assignment_css = '#course-grading-assignment-name'
|
||||
assignments = world.css_find(assignment_css)
|
||||
types = [ele['value'] for ele in assignments]
|
||||
assert name in types
|
||||
|
||||
|
||||
@step(u'I change the highest grade range to "(.*)"$')
|
||||
def change_grade_range(_step, range_name):
|
||||
range_css = 'span.letter-grade'
|
||||
grade = world.css_find(range_css).first
|
||||
grade.value = range_name
|
||||
|
||||
|
||||
@step(u'I see the highest grade range is "(.*)"$')
|
||||
def i_see_highest_grade_range(_step, range_name):
|
||||
range_css = 'span.letter-grade'
|
||||
grade = world.css_find(range_css).first
|
||||
assert grade.value == range_name
|
||||
|
||||
|
||||
@step(u'I cannot edit the "Fail" grade range$')
|
||||
def cannot_edit_fail(_step):
|
||||
range_css = 'span.letter-grade'
|
||||
ranges = world.css_find(range_css)
|
||||
assert len(ranges) == 2
|
||||
try:
|
||||
ranges.last.value = 'Failure'
|
||||
assert False, "Should not be able to edit failing range"
|
||||
except InvalidElementStateException:
|
||||
pass # We should get this exception on failing to edit the element
|
||||
|
||||
|
||||
|
||||
@step(u'I change the grace period to "(.*)"$')
|
||||
def i_change_grace_period(_step, grace_period):
|
||||
grace_period_css = '#course-grading-graceperiod'
|
||||
ele = world.css_find(grace_period_css).first
|
||||
ele.value = grace_period
|
||||
|
||||
|
||||
@step(u'I see the grace period is "(.*)"$')
|
||||
def the_grace_period_is(_step, grace_period):
|
||||
grace_period_css = '#course-grading-graceperiod'
|
||||
ele = world.css_find(grace_period_css).first
|
||||
assert ele.value == grace_period
|
||||
|
||||
|
||||
def get_type_index(name):
|
||||
|
||||
@@ -6,6 +6,8 @@ Feature: HTML Editor
|
||||
And I edit and select Settings
|
||||
Then I see only the HTML display name setting
|
||||
|
||||
# Safari doesn't save the name properly
|
||||
@skip_safari
|
||||
Scenario: User can modify display name
|
||||
Given I have created a Blank HTML Page
|
||||
And I edit and select Settings
|
||||
|
||||
@@ -7,12 +7,16 @@ Feature: Problem Editor
|
||||
Then I see five alphabetized settings and their expected values
|
||||
And Edit High Level Source is not visible
|
||||
|
||||
# Safari is having trouble saving the values on sauce
|
||||
@skip_safari
|
||||
Scenario: User can modify String values
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
|
||||
# Safari is having trouble saving the values on sauce
|
||||
@skip_safari
|
||||
Scenario: User can specify special characters in String values
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
@@ -25,6 +29,8 @@ Feature: Problem Editor
|
||||
Then I can revert the display name to unset
|
||||
And my display name is unset on save
|
||||
|
||||
# IE will not click the revert button properly
|
||||
@skip_internetexplorer
|
||||
Scenario: User can select values in a Select
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
@@ -32,6 +38,8 @@ Feature: Problem Editor
|
||||
And my change to randomization is persisted
|
||||
And I can revert to the default value for randomization
|
||||
|
||||
# Safari will input it as 35.
|
||||
@skip_safari
|
||||
Scenario: User can modify float input values
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
@@ -44,16 +52,22 @@ Feature: Problem Editor
|
||||
When I edit and select Settings
|
||||
Then if I set the weight to "abc", it remains unset
|
||||
|
||||
# Safari will input it as 234.
|
||||
@skip_safari
|
||||
Scenario: User cannot type decimal values integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "2.34", it will persist as a valid integer
|
||||
|
||||
# Safari will input it incorrectly
|
||||
@skip_safari
|
||||
Scenario: User cannot type out of range values in an integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "-3", it will persist as a valid integer
|
||||
|
||||
# Safari will input it as 35.
|
||||
@skip_safari
|
||||
Scenario: Settings changes are not saved on Cancel
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
@@ -67,6 +81,8 @@ Feature: Problem Editor
|
||||
Then Edit High Level Source is visible
|
||||
|
||||
# This feature will work in Firefox only when Firefox is the active window
|
||||
# IE will not interact with the high level source in sauce labs
|
||||
@skip_internetexplorer
|
||||
Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
|
||||
Given I have created a LaTeX Problem
|
||||
When I edit and compile the High Level Source
|
||||
|
||||
@@ -15,6 +15,8 @@ Feature: Static Pages
|
||||
And I "delete" the "Empty" page
|
||||
Then I should not see a "Empty" static page
|
||||
|
||||
# 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
|
||||
|
||||
@@ -25,6 +25,8 @@ Feature: Create Subsection
|
||||
And I reload the page
|
||||
Then I see it marked as Homework
|
||||
|
||||
# Safari has trouble saving the date in Sauce
|
||||
@skip_safari
|
||||
Scenario: Set a due date in a different year (bug #256)
|
||||
Given I have opened a new subsection in Studio
|
||||
And I set the subsection release date to 12/25/2011 03:00
|
||||
|
||||
@@ -5,6 +5,9 @@ Feature: Textbooks
|
||||
When I go to the textbooks page
|
||||
Then I should see a message telling me to create a new textbook
|
||||
|
||||
# IE and Safari on sauce labs will not upload the textbook correctly resulting in an error
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: Create a textbook
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the textbooks page
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from lettuce import world, step
|
||||
from django.conf import settings
|
||||
import os
|
||||
from common import upload_file
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
@@ -24,14 +24,8 @@ def assert_create_new_textbook_msg(_step):
|
||||
|
||||
|
||||
@step(u'I upload the textbook "([^"]*)"$')
|
||||
def upload_file(_step, file_name):
|
||||
file_css = '.upload-dialog input[type=file]'
|
||||
upload = world.css_find(file_css)
|
||||
# uploading the file itself
|
||||
path = os.path.join(TEST_ROOT, 'uploads', file_name)
|
||||
upload._element.send_keys(os.path.abspath(path))
|
||||
button_css = ".upload-dialog .action-upload"
|
||||
world.css_click(button_css)
|
||||
def upload_textbook(_step, file_name):
|
||||
upload_file(file_name)
|
||||
|
||||
|
||||
@step(u'I click (on )?the New Textbook button')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
Feature: Upload Files
|
||||
As a course author, I want to be able to upload files for my students
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can upload files
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the files and uploads page
|
||||
@@ -8,6 +10,8 @@ Feature: Upload Files
|
||||
Then I should see the file "test" was uploaded
|
||||
And The url for the file "test" is valid
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can update files
|
||||
Given I have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
@@ -15,6 +19,8 @@ Feature: Upload Files
|
||||
And I upload the file "test"
|
||||
Then I should see only one "test"
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can delete uploaded files
|
||||
Given I have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
@@ -23,12 +29,16 @@ Feature: Upload Files
|
||||
Then I should not see the file "test" was uploaded
|
||||
And I see a confirmation that the file was deleted
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can download files
|
||||
Given I have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
When I upload the file "test"
|
||||
Then I can download the correct "test" file
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
Scenario: Users can download updated files
|
||||
Given I have opened a new course in studio
|
||||
And I go to the files and uploads page
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
|
||||
@step(u'I go to the files and uploads page')
|
||||
def go_to_uploads(_step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
@@ -106,8 +107,8 @@ def get_index(file_name):
|
||||
def get_file(file_name):
|
||||
index = get_index(file_name)
|
||||
assert index != -1
|
||||
|
||||
url_css = 'a.filename'
|
||||
|
||||
def get_url():
|
||||
return world.css_find(url_css)[index]._element.get_attribute('href')
|
||||
url = world.retry_on_exception(get_url)
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
Feature: Video Component Editor
|
||||
As a course author, I want to be able to create video components.
|
||||
|
||||
Scenario: User can view metadata
|
||||
Scenario: User can view Video metadata
|
||||
Given I have created a Video component
|
||||
And I edit and select Settings
|
||||
Then I see the correct settings and default values
|
||||
And I edit the component
|
||||
Then I see the correct video settings and default values
|
||||
|
||||
Scenario: User can modify display name
|
||||
# Safari has trouble saving values on Sauce
|
||||
@skip_safari
|
||||
Scenario: User can modify Video display name
|
||||
Given I have created a Video component
|
||||
And I edit and select Settings
|
||||
And I edit the component
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
And my video display name change is persisted on save
|
||||
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are hidden when "show captions" is false
|
||||
Given I have created a Video component
|
||||
And I have set "show captions" to False
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are shown when "show captions" is true
|
||||
Given I have created a Video component
|
||||
And I have set "show captions" to True
|
||||
|
||||
@@ -2,21 +2,10 @@
|
||||
# pylint: disable=C0111
|
||||
|
||||
from lettuce import world, step
|
||||
from terrain.steps import reload_the_page
|
||||
|
||||
|
||||
@step('I see the correct settings and default values$')
|
||||
def i_see_the_correct_settings_and_values(step):
|
||||
world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False],
|
||||
['Display Name', 'Video', False],
|
||||
['Download Track', '', False],
|
||||
['Download Video', '', False],
|
||||
['Show Captions', 'True', False],
|
||||
['Speed: .75x', '', False],
|
||||
['Speed: 1.25x', '', False],
|
||||
['Speed: 1.5x', '', False]])
|
||||
|
||||
|
||||
@step('I have set "show captions" to (.*)')
|
||||
@step('I have set "show captions" to (.*)$')
|
||||
def set_show_captions(step, setting):
|
||||
world.css_click('a.edit-button')
|
||||
world.wait_for(lambda _driver: world.css_visible('a.save-button'))
|
||||
@@ -24,9 +13,19 @@ def set_show_captions(step, setting):
|
||||
world.css_click('a.save-button')
|
||||
|
||||
|
||||
@step('I see the correct videoalpha settings and default values$')
|
||||
def correct_videoalpha_settings(_step):
|
||||
world.verify_all_setting_entries([['Display Name', 'Video Alpha', False],
|
||||
@step('when I view the (video.*) it (.*) show the captions$')
|
||||
def shows_captions(_step, video_type, show_captions):
|
||||
# Prevent cookies from overriding course settings
|
||||
world.browser.cookies.delete('hide_captions')
|
||||
if show_captions == 'does not':
|
||||
assert world.css_has_class('.%s' % video_type, 'closed')
|
||||
else:
|
||||
assert world.is_css_not_present('.%s.closed' % video_type)
|
||||
|
||||
|
||||
@step('I see the correct video settings and default values$')
|
||||
def correct_video_settings(_step):
|
||||
world.verify_all_setting_entries([['Display Name', 'Video', False],
|
||||
['Download Track', '', False],
|
||||
['Download Video', '', False],
|
||||
['End Time', '0', False],
|
||||
@@ -38,3 +37,12 @@ def correct_videoalpha_settings(_step):
|
||||
['Youtube ID for .75x speed', '', False],
|
||||
['Youtube ID for 1.25x speed', '', False],
|
||||
['Youtube ID for 1.5x speed', '', False]])
|
||||
|
||||
|
||||
@step('my video display name change is persisted on save$')
|
||||
def video_name_persisted(step):
|
||||
world.css_click('a.save-button')
|
||||
reload_the_page(step)
|
||||
world.edit_component()
|
||||
world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
Feature: Video Component
|
||||
As a course author, I want to be able to view my created videos in Studio.
|
||||
|
||||
# Video Alpha Features will work in Firefox only when Firefox is the active window
|
||||
Scenario: Autoplay is disabled in Studio
|
||||
Given I have created a Video component
|
||||
Then when I view the video it does not have autoplay enabled
|
||||
@@ -9,46 +10,26 @@ Feature: Video Component
|
||||
Given I have clicked the new unit button
|
||||
Then creating a video takes a single click
|
||||
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are hidden correctly
|
||||
Given I have created a Video component
|
||||
And I have hidden captions
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are shown correctly
|
||||
Given I have created a Video component
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
# Sauce Labs cannot delete cookies
|
||||
@skip_sauce
|
||||
Scenario: Captions are toggled correctly
|
||||
Given I have created a Video component
|
||||
And I have toggled captions
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
# Video Alpha Features will work in Firefox only when Firefox is the active window
|
||||
Scenario: Autoplay is disabled in Studio for Video Alpha
|
||||
Given I have created a Video Alpha component
|
||||
Then when I view the videoalpha it does not have autoplay enabled
|
||||
|
||||
Scenario: User can view Video Alpha metadata
|
||||
Given I have created a Video Alpha component
|
||||
And I edit the component
|
||||
Then I see the correct videoalpha settings and default values
|
||||
|
||||
Scenario: User can modify Video Alpha display name
|
||||
Given I have created a Video Alpha component
|
||||
And I edit the component
|
||||
Then I can modify the display name
|
||||
And my videoalpha display name change is persisted on save
|
||||
|
||||
Scenario: Video Alpha captions are hidden when "show captions" is false
|
||||
Given I have created a Video Alpha component
|
||||
And I have set "show captions" to False
|
||||
Then when I view the videoalpha it does not show the captions
|
||||
|
||||
Scenario: Video Alpha captions are shown when "show captions" is true
|
||||
Given I have created a Video Alpha component
|
||||
And I have set "show captions" to True
|
||||
Then when I view the videoalpha it does show the captions
|
||||
|
||||
Scenario: Video data is shown correctly
|
||||
Given I have created a video with only XML data
|
||||
Then the correct Youtube video is shown
|
||||
|
||||
@@ -9,20 +9,35 @@ from contentstore.utils import get_modulestore
|
||||
############### ACTIONS ####################
|
||||
|
||||
|
||||
@step('when I view the (.*) it does not have autoplay enabled')
|
||||
@step('I have created a Video component$')
|
||||
def i_created_a_video_component(step):
|
||||
world.create_component_instance(
|
||||
step, '.large-video-icon',
|
||||
'video',
|
||||
'.xmodule_VideoModule',
|
||||
has_multiple_templates=False
|
||||
)
|
||||
|
||||
|
||||
@step('when I view the (.*) it does not have autoplay enabled$')
|
||||
def does_not_autoplay(_step, video_type):
|
||||
assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False'
|
||||
assert world.css_has_class('.video_control', 'play')
|
||||
|
||||
|
||||
@step('creating a video takes a single click')
|
||||
@step('creating a video takes a single click$')
|
||||
def video_takes_a_single_click(_step):
|
||||
assert(not world.is_css_present('.xmodule_VideoModule'))
|
||||
world.css_click("a[data-category='video']")
|
||||
assert(world.is_css_present('.xmodule_VideoModule'))
|
||||
|
||||
|
||||
@step('I have (hidden|toggled) captions')
|
||||
@step('I edit the component$')
|
||||
def i_edit_the_component(_step):
|
||||
world.edit_component()
|
||||
|
||||
|
||||
@step('I have (hidden|toggled) captions$')
|
||||
def hide_or_show_captions(step, shown):
|
||||
button_css = 'a.hide-subtitles'
|
||||
if shown == 'hidden':
|
||||
@@ -38,20 +53,8 @@ def hide_or_show_captions(step, shown):
|
||||
button.mouse_out()
|
||||
world.css_click(button_css)
|
||||
|
||||
@step('I edit the component')
|
||||
def i_edit_the_component(_step):
|
||||
world.edit_component()
|
||||
|
||||
|
||||
@step('my videoalpha display name change is persisted on save')
|
||||
def videoalpha_name_persisted(step):
|
||||
world.css_click('a.save-button')
|
||||
reload_the_page(step)
|
||||
world.edit_component()
|
||||
world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True)
|
||||
|
||||
|
||||
@step('I have created a video with only XML data')
|
||||
@step('I have created a video with only XML data$')
|
||||
def xml_only_video(step):
|
||||
# Create a new video *without* metadata. This requires a certain
|
||||
# amount of rummaging to make sure all the correct data is present
|
||||
@@ -81,7 +84,8 @@ def xml_only_video(step):
|
||||
reload_the_page(step)
|
||||
|
||||
|
||||
@step('The correct Youtube video is shown')
|
||||
@step('The correct Youtube video is shown$')
|
||||
def the_youtube_video_is_shown(_step):
|
||||
ele = world.css_find('.video').first
|
||||
assert ele['data-youtube-id-1-0'] == world.scenario_dict['YOUTUBE_ID']
|
||||
assert ele['data-streams'].split(':')[1] == world.scenario_dict['YOUTUBE_ID']
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ from xmodule.course_module import CourseDescriptor
|
||||
|
||||
from request_cache.middleware import RequestCache
|
||||
|
||||
from django.core.cache import get_cache
|
||||
|
||||
CACHE = get_cache('mongo_metadata_inheritance')
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Enumerates through the course and find common errors'''
|
||||
@@ -19,7 +22,10 @@ class Command(BaseCommand):
|
||||
store = modulestore()
|
||||
|
||||
# setup a request cache so we don't throttle the DB with all the metadata inheritance requests
|
||||
store.request_cache = RequestCache.get_request_cache()
|
||||
store.set_modulestore_configuration({
|
||||
'metadata_inheritance_cache_subsystem': CACHE,
|
||||
'request_cache': RequestCache.get_request_cache()
|
||||
})
|
||||
|
||||
course = store.get_item(loc, depth=3)
|
||||
|
||||
|
||||
@@ -15,10 +15,6 @@ from auth.authz import _copy_course_group
|
||||
from request_cache.middleware import RequestCache
|
||||
from django.core.cache import get_cache
|
||||
|
||||
#
|
||||
# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
|
||||
#
|
||||
|
||||
CACHE = get_cache('mongo_metadata_inheritance')
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -36,8 +32,11 @@ class Command(BaseCommand):
|
||||
mstore = modulestore('direct')
|
||||
cstore = contentstore()
|
||||
|
||||
mstore.metadata_inheritance_cache_subsystem = CACHE
|
||||
mstore.request_cache = RequestCache.get_request_cache()
|
||||
mstore.set_modulestore_configuration({
|
||||
'metadata_inheritance_cache_subsystem': CACHE,
|
||||
'request_cache': RequestCache.get_request_cache()
|
||||
})
|
||||
|
||||
org, course_num, run = dest_course_id.split("/")
|
||||
mstore.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
|
||||
|
||||
|
||||
@@ -36,8 +36,11 @@ class Command(BaseCommand):
|
||||
ms = modulestore('direct')
|
||||
cs = contentstore()
|
||||
|
||||
ms.metadata_inheritance_cache_subsystem = CACHE
|
||||
ms.request_cache = RequestCache.get_request_cache()
|
||||
ms.set_modulestore_configuration({
|
||||
'metadata_inheritance_cache_subsystem': CACHE,
|
||||
'request_cache': RequestCache.get_request_cache()
|
||||
})
|
||||
|
||||
org, course_num, run = course_id.split("/")
|
||||
ms.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num))
|
||||
|
||||
@@ -48,4 +51,7 @@ class Command(BaseCommand):
|
||||
print 'removing User permissions from course....'
|
||||
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||
if commit:
|
||||
_delete_course_group(loc)
|
||||
try:
|
||||
_delete_course_group(loc)
|
||||
except Exception as err:
|
||||
print("Error in deleting course groups for {0}: {1}".format(loc, err))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Script for importing courseware from XML format
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.management.base import BaseCommand, CommandError, make_option
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
@@ -14,18 +14,26 @@ class Command(BaseCommand):
|
||||
"""
|
||||
help = 'Import the specified data directory into the default ModuleStore'
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--nostatic',
|
||||
action='store_true',
|
||||
help='Skip import of static content'),
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) == 0:
|
||||
raise CommandError("import requires at least one argument: <data directory> [<course dir>...]")
|
||||
raise CommandError("import requires at least one argument: <data directory> [--nostatic] [<course dir>...]")
|
||||
|
||||
data_dir = args[0]
|
||||
do_import_static = not (options.get('nostatic', False))
|
||||
if len(args) > 1:
|
||||
course_dirs = args[1:]
|
||||
else:
|
||||
course_dirs = None
|
||||
print("Importing. Data_dir={data}, course_dirs={courses}".format(
|
||||
data=data_dir,
|
||||
courses=course_dirs))
|
||||
courses=course_dirs,
|
||||
dis=do_import_static))
|
||||
import_from_xml(modulestore('direct'), data_dir, course_dirs, load_error_modules=False,
|
||||
static_content_store=contentstore(), verbose=True)
|
||||
static_content_store=contentstore(), verbose=True, do_import_static=do_import_static)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from static_replace import replace_static_urls
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
|
||||
def get_module_info(store, location, rewrite_static_links=False):
|
||||
@@ -13,16 +12,12 @@ def get_module_info(store, location, rewrite_static_links=False):
|
||||
|
||||
data = module.data
|
||||
if rewrite_static_links:
|
||||
# we pass a partially bogus course_id as we don't have the RUN information passed yet
|
||||
# through the CMS. Also the contentstore is also not RUN-aware at this point in time.
|
||||
data = replace_static_urls(
|
||||
module.data,
|
||||
None,
|
||||
course_namespace=Location([
|
||||
module.location.tag,
|
||||
module.location.org,
|
||||
module.location.course,
|
||||
None,
|
||||
None
|
||||
])
|
||||
course_id=module.location.org + '/' + module.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,7 @@ import datetime
|
||||
from pytz import UTC
|
||||
from uuid import uuid4
|
||||
from pymongo import MongoClient
|
||||
from student.views import is_enrolled_in_course
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
@@ -107,8 +107,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
expected_types is the list of elements that should appear on the page.
|
||||
|
||||
expected_types and component_types should be similar, but not
|
||||
exactly the same -- for example, 'videoalpha' in
|
||||
component_types should cause 'Video Alpha' to be present.
|
||||
exactly the same -- for example, 'video' in
|
||||
component_types should cause 'Video' to be present.
|
||||
"""
|
||||
store = modulestore('direct')
|
||||
import_from_xml(store, 'common/test/data/', ['simple'])
|
||||
@@ -136,14 +136,13 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
def test_advanced_components_in_edit_unit(self):
|
||||
# This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page
|
||||
# response HTML
|
||||
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha',
|
||||
'Word cloud',
|
||||
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Word cloud',
|
||||
'Annotation',
|
||||
'Open Response Assessment',
|
||||
'Peer Grading Interface'])
|
||||
|
||||
def test_advanced_components_require_two_clicks(self):
|
||||
self.check_components_on_page(['videoalpha'], ['Video Alpha'])
|
||||
self.check_components_on_page(['word_cloud'], ['Word cloud'])
|
||||
|
||||
def test_malformed_edit_unit_request(self):
|
||||
store = modulestore('direct')
|
||||
@@ -401,6 +400,20 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_video_module_caption_asset_path(self):
|
||||
'''
|
||||
This verifies that a video caption url is as we expect it to be
|
||||
'''
|
||||
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
|
||||
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(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"')
|
||||
|
||||
def test_delete(self):
|
||||
direct_store = modulestore('direct')
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
@@ -463,7 +476,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
content_store = contentstore()
|
||||
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store)
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store, verbose=True)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
course = module_store.get_item(course_location)
|
||||
@@ -644,6 +657,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
content_store = contentstore()
|
||||
|
||||
# now do the actual cloning
|
||||
clone_course(module_store, content_store, source_location, dest_location)
|
||||
|
||||
# first assert that all draft content got cloned as well
|
||||
@@ -693,6 +707,56 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
expected_children.append(child_loc.url())
|
||||
self.assertEqual(expected_children, lookup_item.children)
|
||||
|
||||
def test_portable_link_rewrites_during_clone_course(self):
|
||||
course_data = {
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
'run': '2013_Spring'
|
||||
}
|
||||
|
||||
module_store = modulestore('direct')
|
||||
draft_store = modulestore('draft')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
source_course_id = 'edX/toy/2012_Fall'
|
||||
dest_course_id = 'MITx/999/2013_Spring'
|
||||
source_location = CourseDescriptor.id_to_location(source_course_id)
|
||||
dest_location = CourseDescriptor.id_to_location(dest_course_id)
|
||||
|
||||
# let's force a non-portable link in the clone source
|
||||
# as a final check, make sure that any non-portable links are rewritten during cloning
|
||||
html_module_location = Location([
|
||||
source_location.tag, source_location.org, source_location.course, 'html', 'nonportable'])
|
||||
html_module = module_store.get_instance(source_location.course_id, html_module_location)
|
||||
|
||||
self.assertTrue(isinstance(html_module.data, basestring))
|
||||
new_data = html_module.data.replace('/static/', '/c4x/{0}/{1}/asset/'.format(
|
||||
source_location.org, source_location.course))
|
||||
module_store.update_item(html_module_location, new_data)
|
||||
|
||||
html_module = module_store.get_instance(source_location.course_id, html_module_location)
|
||||
self.assertEqual(new_data, html_module.data)
|
||||
|
||||
# create the destination course
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/999/course/2013_Spring')
|
||||
|
||||
# do the actual cloning
|
||||
clone_course(module_store, content_store, source_location, dest_location)
|
||||
|
||||
# make sure that any non-portable links are rewritten during cloning
|
||||
html_module_location = Location([
|
||||
dest_location.tag, dest_location.org, dest_location.course, 'html', 'nonportable'])
|
||||
html_module = module_store.get_instance(dest_location.course_id, html_module_location)
|
||||
|
||||
self.assertIn('/static/foo.jpg', html_module.data)
|
||||
|
||||
def test_illegal_draft_crud_ops(self):
|
||||
draft_store = modulestore('draft')
|
||||
direct_store = modulestore('direct')
|
||||
@@ -720,6 +784,22 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_rewrite_nonportable_links_on_import(self):
|
||||
module_store = modulestore('direct')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store)
|
||||
|
||||
# first check a static asset link
|
||||
html_module_location = Location(['i4x', 'edX', 'toy', 'html', 'nonportable'])
|
||||
html_module = module_store.get_instance('edX/toy/2012_Fall', html_module_location)
|
||||
self.assertIn('/static/foo.jpg', html_module.data)
|
||||
|
||||
# then check a intra courseware link
|
||||
html_module_location = Location(['i4x', 'edX', 'toy', 'html', 'nonportable_link'])
|
||||
html_module = module_store.get_instance('edX/toy/2012_Fall', html_module_location)
|
||||
self.assertIn('/jump_to_id/nonportable_link', html_module.data)
|
||||
|
||||
def test_delete_course(self):
|
||||
"""
|
||||
This test will import a course, make a draft item, and delete it. This will also assert that the
|
||||
@@ -865,8 +945,17 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
'vertical', 'vertical_test', None]), depth=1)
|
||||
|
||||
self.assertTrue(getattr(vertical, 'is_draft', False))
|
||||
self.assertNotIn('index_in_children_list', child.xml_attributes)
|
||||
self.assertNotIn('parent_sequential_url', vertical.xml_attributes)
|
||||
|
||||
for child in vertical.get_children():
|
||||
self.assertTrue(getattr(child, 'is_draft', False))
|
||||
self.assertNotIn('index_in_children_list', child.xml_attributes)
|
||||
if hasattr(child, 'data'):
|
||||
self.assertNotIn('index_in_children_list', child.data)
|
||||
self.assertNotIn('parent_sequential_url', child.xml_attributes)
|
||||
if hasattr(child, 'data'):
|
||||
self.assertNotIn('parent_sequential_url', child.data)
|
||||
|
||||
# make sure that we don't have a sequential that is in draft mode
|
||||
sequential = draft_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
@@ -977,6 +1066,38 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# It should now contain empty data
|
||||
self.assertEquals(imported_word_cloud.data, '')
|
||||
|
||||
def test_html_export_roundtrip(self):
|
||||
"""
|
||||
Test that a course which has HTML that has style formatting is preserved in export/import
|
||||
"""
|
||||
module_store = modulestore('direct')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
|
||||
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
|
||||
# Export the course
|
||||
root_dir = path(mkdtemp_clean())
|
||||
export_to_xml(module_store, content_store, location, root_dir, 'test_roundtrip')
|
||||
|
||||
# Reimport and get the video back
|
||||
import_from_xml(module_store, root_dir)
|
||||
|
||||
# get the sample HTML with styling information
|
||||
html_module = module_store.get_instance(
|
||||
'edX/toy/2012_Fall',
|
||||
Location(['i4x', 'edX', 'toy', 'html', 'with_styling'])
|
||||
)
|
||||
self.assertIn('<p style="font:italic bold 72px/30px Georgia, serif; color: red; ">', html_module.data)
|
||||
|
||||
# get the sample HTML with just a simple <img> tag information
|
||||
html_module = module_store.get_instance(
|
||||
'edX/toy/2012_Fall',
|
||||
Location(['i4x', 'edX', 'toy', 'html', 'just_img'])
|
||||
)
|
||||
self.assertIn('<img src="/static/foo_bar.jpg" />', html_module.data)
|
||||
|
||||
def test_course_handouts_rewrites(self):
|
||||
module_store = modulestore('direct')
|
||||
|
||||
@@ -1102,7 +1223,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
self.assertNotIn('ErrMsg', data)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/{0}/course/2013_Spring'.format(test_course_data['number']))
|
||||
# Verify that the creator is now registered in the course.
|
||||
self.assertTrue(is_enrolled_in_course(self.user, self._get_course_id(test_course_data)))
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self._get_course_id(test_course_data)))
|
||||
return test_course_data
|
||||
|
||||
def test_create_course_check_forum_seeding(self):
|
||||
@@ -1124,14 +1245,14 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
Checks that the course did not get created
|
||||
"""
|
||||
course_id = self._get_course_id(self.course_data)
|
||||
initially_enrolled = is_enrolled_in_course(self.user, course_id)
|
||||
initially_enrolled = CourseEnrollment.is_enrolled(self.user, course_id)
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['ErrMsg'], error_message)
|
||||
# One test case involves trying to create the same course twice. Hence for that course,
|
||||
# the user will be enrolled. In the other cases, initially_enrolled will be False.
|
||||
self.assertEqual(initially_enrolled, is_enrolled_in_course(self.user, course_id))
|
||||
self.assertEqual(initially_enrolled, CourseEnrollment.is_enrolled(self.user, course_id))
|
||||
|
||||
def test_create_course_duplicate_number(self):
|
||||
"""Test new course creation - error path"""
|
||||
@@ -1287,7 +1408,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
self.assertContains(resp, 'Chapter 2')
|
||||
|
||||
# go to various pages
|
||||
@@ -1297,92 +1418,134 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# export page
|
||||
resp = self.client.get(reverse('export_course',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# manage users
|
||||
resp = self.client.get(reverse('manage_users',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# course info
|
||||
resp = self.client.get(reverse('course_info',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# settings_details
|
||||
resp = self.client.get(reverse('settings_details',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# settings_details
|
||||
resp = self.client.get(reverse('settings_grading',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# static_pages
|
||||
resp = self.client.get(reverse('static_pages',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'coursename': loc.name}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# static_pages
|
||||
resp = self.client.get(reverse('asset_index',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# go look at a subsection page
|
||||
subsection_location = loc.replace(category='sequential', name='test_sequence')
|
||||
resp = self.client.get(reverse('edit_subsection',
|
||||
kwargs={'location': subsection_location.url()}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# go look at the Edit page
|
||||
unit_location = loc.replace(category='vertical', name='test_vertical')
|
||||
resp = self.client.get(reverse('edit_unit',
|
||||
kwargs={'location': unit_location.url()}))
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# delete a component
|
||||
del_loc = loc.replace(category='html', name='test_html')
|
||||
resp = self.client.post(reverse('delete_item'),
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# delete a unit
|
||||
del_loc = loc.replace(category='vertical', name='test_vertical')
|
||||
resp = self.client.post(reverse('delete_item'),
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# delete a unit
|
||||
del_loc = loc.replace(category='sequential', name='test_sequence')
|
||||
resp = self.client.post(reverse('delete_item'),
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
# delete a chapter
|
||||
del_loc = loc.replace(category='chapter', name='chapter_2')
|
||||
resp = self.client.post(reverse('delete_item'),
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
def test_import_into_new_course_id(self):
|
||||
module_store = modulestore('direct')
|
||||
target_location = Location(['i4x', 'MITx', '999', 'course', '2013_Spring'])
|
||||
|
||||
course_data = {
|
||||
'org': target_location.org,
|
||||
'number': target_location.course,
|
||||
'display_name': 'Robot Super Course',
|
||||
'run': target_location.name
|
||||
}
|
||||
|
||||
target_course_id = '{0}/{1}/{2}'.format(target_location.org, target_location.course, target_location.name)
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], target_location.url())
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], target_location_namespace=target_location)
|
||||
|
||||
modules = module_store.get_items(Location([
|
||||
target_location.tag, target_location.org, target_location.course, None, None, None]))
|
||||
|
||||
# we should have a number of modules in there
|
||||
# we can't specify an exact number since it'll always be changing
|
||||
self.assertGreater(len(modules), 10)
|
||||
|
||||
#
|
||||
# test various re-namespacing elements
|
||||
#
|
||||
|
||||
# first check PDF textbooks, to make sure the url paths got updated
|
||||
course_module = module_store.get_instance(target_course_id, target_location)
|
||||
|
||||
self.assertEquals(len(course_module.pdf_textbooks), 1)
|
||||
self.assertEquals(len(course_module.pdf_textbooks[0]["chapters"]), 2)
|
||||
self.assertEquals(course_module.pdf_textbooks[0]["chapters"][0]["url"], '/c4x/MITx/999/asset/Chapter1.pdf')
|
||||
self.assertEquals(course_module.pdf_textbooks[0]["chapters"][1]["url"], '/c4x/MITx/999/asset/Chapter2.pdf')
|
||||
|
||||
# check that URL slug got updated to new course slug
|
||||
self.assertEquals(course_module.wiki_slug, '999')
|
||||
|
||||
def test_import_metadata_with_attempts_empty_string(self):
|
||||
module_store = modulestore('direct')
|
||||
@@ -1503,14 +1666,40 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
# is this test too strict? i.e., it requires the dicts to be ==
|
||||
self.assertEqual(course.checklists, fetched_course.checklists)
|
||||
|
||||
def test_image_import(self):
|
||||
"""Test backwards compatibilty of course image."""
|
||||
module_store = modulestore('direct')
|
||||
|
||||
content_store = contentstore()
|
||||
|
||||
# Use conditional_and_poll, as it's got an image already
|
||||
import_from_xml(
|
||||
module_store,
|
||||
'common/test/data/',
|
||||
['conditional_and_poll'],
|
||||
static_content_store=content_store
|
||||
)
|
||||
|
||||
course = module_store.get_courses()[0]
|
||||
|
||||
# Make sure the course image is set to the right place
|
||||
self.assertEqual(course.course_image, 'images_course_image.jpg')
|
||||
|
||||
# Ensure that the imported course image is present -- this shouldn't raise an exception
|
||||
location = course.location._replace(tag='c4x', category='asset', name=course.course_image)
|
||||
content_store.find(location)
|
||||
|
||||
|
||||
class MetadataSaveTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Test that metadata is correctly decached.
|
||||
"""
|
||||
"""Test that metadata is correctly cached and decached."""
|
||||
|
||||
def setUp(self):
|
||||
sample_xml = '''
|
||||
CourseFactory.create(
|
||||
org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(
|
||||
['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
video_sample_xml = '''
|
||||
<video display_name="Test Video"
|
||||
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
|
||||
show_captions="false"
|
||||
@@ -1520,19 +1709,17 @@ class MetadataSaveTestCase(ModuleStoreTestCase):
|
||||
<track src="http://www.example.com/track"/>
|
||||
</video>
|
||||
'''
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
self.video_descriptor = ItemFactory.create(
|
||||
parent_location=course_location, category='video',
|
||||
data={'data': video_sample_xml}
|
||||
)
|
||||
|
||||
model_data = {'data': sample_xml}
|
||||
self.descriptor = ItemFactory.create(parent_location=course_location, category='video', data=model_data)
|
||||
|
||||
def test_metadata_persistence(self):
|
||||
def test_metadata_not_persistence(self):
|
||||
"""
|
||||
Test that descriptors which set metadata fields in their
|
||||
constructor are correctly persisted.
|
||||
constructor are correctly deleted.
|
||||
"""
|
||||
# We should start with a source field, from the XML's <source/> tag
|
||||
self.assertIn('source', own_metadata(self.descriptor))
|
||||
self.assertIn('html5_sources', own_metadata(self.video_descriptor))
|
||||
attrs_to_strip = {
|
||||
'show_captions',
|
||||
'youtube_id_1_0',
|
||||
@@ -1542,23 +1729,27 @@ class MetadataSaveTestCase(ModuleStoreTestCase):
|
||||
'start_time',
|
||||
'end_time',
|
||||
'source',
|
||||
'html5_sources',
|
||||
'track'
|
||||
}
|
||||
# We strip out all metadata fields to reproduce a bug where
|
||||
# constructors which set their fields (e.g. Video) didn't have
|
||||
# those changes persisted. So in the end we have the XML data
|
||||
# in `descriptor.data`, but not in the individual fields
|
||||
fields = self.descriptor.fields
|
||||
|
||||
fields = self.video_descriptor.fields
|
||||
location = self.video_descriptor.location
|
||||
|
||||
for field in fields:
|
||||
if field.name in attrs_to_strip:
|
||||
field.delete_from(self.descriptor)
|
||||
field.delete_from(self.video_descriptor)
|
||||
|
||||
# Assert that we correctly stripped the field
|
||||
self.assertNotIn('source', own_metadata(self.descriptor))
|
||||
get_modulestore(self.descriptor.location).update_metadata(
|
||||
self.descriptor.location,
|
||||
own_metadata(self.descriptor)
|
||||
self.assertNotIn('html5_sources', own_metadata(self.video_descriptor))
|
||||
get_modulestore(location).update_metadata(
|
||||
location,
|
||||
own_metadata(self.video_descriptor)
|
||||
)
|
||||
module = get_modulestore(self.descriptor.location).get_item(self.descriptor.location)
|
||||
# Assert that get_item correctly sets the metadata
|
||||
self.assertIn('source', own_metadata(module))
|
||||
module = get_modulestore(location).get_item(location)
|
||||
|
||||
self.assertNotIn('html5_sources', own_metadata(module))
|
||||
|
||||
def test_metadata_persistence(self):
|
||||
# TODO: create the same test as `test_metadata_not_persistence`,
|
||||
# but check persistence for some other module.
|
||||
pass
|
||||
|
||||
@@ -30,6 +30,7 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
def test_virgin_fetch(self):
|
||||
details = CourseDetails.fetch(self.course.location)
|
||||
self.assertEqual(details.course_location, self.course.location, "Location 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))
|
||||
self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start))
|
||||
@@ -43,6 +44,7 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
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 ")
|
||||
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
|
||||
@@ -97,6 +99,11 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
CourseDetails.update_from_json(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,
|
||||
jsondetails.course_image_name
|
||||
)
|
||||
|
||||
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
|
||||
def test_marketing_site_fetch(self):
|
||||
@@ -188,6 +195,7 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.alter_field(url, details, 'overview', "Overview")
|
||||
self.alter_field(url, details, 'intro_video', "intro_video")
|
||||
self.alter_field(url, details, 'effort', "effort")
|
||||
self.alter_field(url, details, 'course_image_name', "course_image_name")
|
||||
|
||||
def compare_details_with_encoding(self, encoded, details, context):
|
||||
self.compare_date_fields(details, encoded, context, 'start_date')
|
||||
@@ -197,6 +205,7 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==")
|
||||
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
|
||||
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
|
||||
self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==")
|
||||
|
||||
def compare_date_fields(self, details, encoded, context, field):
|
||||
if details[field] is not None:
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
'''
|
||||
Created on May 7, 2013
|
||||
|
||||
@author: dmitchell
|
||||
'''
|
||||
import unittest
|
||||
from xmodule import templates
|
||||
from xmodule.modulestore.tests import persistent_factories
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.html_module import HtmlDescriptor
|
||||
from xmodule.modulestore import inheritance
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
|
||||
|
||||
class TemplateTests(unittest.TestCase):
|
||||
@@ -74,8 +70,8 @@ class TemplateTests(unittest.TestCase):
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
|
||||
display_name='fun test course', user_id='testbot')
|
||||
|
||||
test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
|
||||
'metadata': {'display_name': 'chapter n'}},
|
||||
test_chapter = self.load_from_json({'category': 'chapter',
|
||||
'fields': {'display_name': 'chapter n'}},
|
||||
test_course.system, parent_xblock=test_course)
|
||||
self.assertIsInstance(test_chapter, SequenceDescriptor)
|
||||
self.assertEqual(test_chapter.display_name, 'chapter n')
|
||||
@@ -83,8 +79,8 @@ class TemplateTests(unittest.TestCase):
|
||||
|
||||
# test w/ a definition (e.g., a problem)
|
||||
test_def_content = '<problem>boo</problem>'
|
||||
test_problem = XModuleDescriptor.load_from_json({'category': 'problem',
|
||||
'definition': {'data': test_def_content}},
|
||||
test_problem = self.load_from_json({'category': 'problem',
|
||||
'fields': {'data': test_def_content}},
|
||||
test_course.system, parent_xblock=test_chapter)
|
||||
self.assertIsInstance(test_problem, CapaDescriptor)
|
||||
self.assertEqual(test_problem.data, test_def_content)
|
||||
@@ -98,12 +94,13 @@ class TemplateTests(unittest.TestCase):
|
||||
"""
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
|
||||
display_name='fun test course', user_id='testbot')
|
||||
test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
|
||||
'metadata': {'display_name': 'chapter n'}},
|
||||
test_chapter = self.load_from_json({'category': 'chapter',
|
||||
'fields': {'display_name': 'chapter n'}},
|
||||
test_course.system, parent_xblock=test_course)
|
||||
test_def_content = '<problem>boo</problem>'
|
||||
test_problem = XModuleDescriptor.load_from_json({'category': 'problem',
|
||||
'definition': {'data': test_def_content}},
|
||||
# create child
|
||||
_ = self.load_from_json({'category': 'problem',
|
||||
'fields': {'data': test_def_content}},
|
||||
test_course.system, parent_xblock=test_chapter)
|
||||
# better to pass in persisted parent over the subdag so
|
||||
# subdag gets the parent pointer (otherwise 2 ops, persist dag, update parent children,
|
||||
@@ -152,15 +149,24 @@ class TemplateTests(unittest.TestCase):
|
||||
parent_location=test_course.location, user_id='testbot')
|
||||
sub = persistent_factories.ItemFactory.create(display_name='subsection 1',
|
||||
parent_location=chapter.location, user_id='testbot', category='vertical')
|
||||
first_problem = persistent_factories.ItemFactory.create(display_name='problem 1',
|
||||
parent_location=sub.location, user_id='testbot', category='problem', data="<problem></problem>")
|
||||
first_problem = persistent_factories.ItemFactory.create(
|
||||
display_name='problem 1', parent_location=sub.location, user_id='testbot', category='problem',
|
||||
data="<problem></problem>"
|
||||
)
|
||||
first_problem.max_attempts = 3
|
||||
first_problem.save() # decache the above into the kvs
|
||||
updated_problem = modulestore('split').update_item(first_problem, 'testbot')
|
||||
updated_loc = modulestore('split').delete_item(updated_problem.location, 'testbot')
|
||||
self.assertIsNotNone(updated_problem.previous_version)
|
||||
self.assertEqual(updated_problem.previous_version, first_problem.update_version)
|
||||
self.assertNotEqual(updated_problem.update_version, first_problem.update_version)
|
||||
updated_loc = modulestore('split').delete_item(updated_problem.location, 'testbot', delete_children=True)
|
||||
|
||||
second_problem = persistent_factories.ItemFactory.create(display_name='problem 2',
|
||||
second_problem = persistent_factories.ItemFactory.create(
|
||||
display_name='problem 2',
|
||||
parent_location=BlockUsageLocator(updated_loc, usage_id=sub.location.usage_id),
|
||||
user_id='testbot', category='problem', data="<problem></problem>")
|
||||
user_id='testbot', category='problem',
|
||||
data="<problem></problem>"
|
||||
)
|
||||
|
||||
# course root only updated 2x
|
||||
version_history = modulestore('split').get_block_generations(test_course.location)
|
||||
@@ -184,3 +190,48 @@ class TemplateTests(unittest.TestCase):
|
||||
|
||||
version_history = modulestore('split').get_block_generations(second_problem.location)
|
||||
self.assertNotEqual(version_history.locator.version_guid, first_problem.location.version_guid)
|
||||
|
||||
# ================================= JSON PARSING ===========================
|
||||
# These are example methods for creating xmodules in memory w/o persisting them.
|
||||
# They were in x_module but since xblock is not planning to support them but will
|
||||
# allow apps to use this type of thing, I put it here.
|
||||
@staticmethod
|
||||
def load_from_json(json_data, system, default_class=None, parent_xblock=None):
|
||||
"""
|
||||
This method instantiates the correct subclass of XModuleDescriptor based
|
||||
on the contents of json_data. It does not persist it and can create one which
|
||||
has no usage id.
|
||||
|
||||
parent_xblock is used to compute inherited metadata as well as to append the new xblock.
|
||||
|
||||
json_data:
|
||||
- 'location' : must have this field
|
||||
- 'category': the xmodule category (required or location must be a Location)
|
||||
- 'metadata': a dict of locally set metadata (not inherited)
|
||||
- 'children': a list of children's usage_ids w/in this course
|
||||
- 'definition':
|
||||
- '_id' (optional): the usage_id of this. Will generate one if not given one.
|
||||
"""
|
||||
class_ = XModuleDescriptor.load_class(
|
||||
json_data.get('category', json_data.get('location', {}).get('category')),
|
||||
default_class
|
||||
)
|
||||
usage_id = json_data.get('_id', None)
|
||||
if not '_inherited_settings' in json_data and parent_xblock is not None:
|
||||
json_data['_inherited_settings'] = parent_xblock.xblock_kvs.get_inherited_settings().copy()
|
||||
json_fields = json_data.get('fields', {})
|
||||
for field in inheritance.INHERITABLE_METADATA:
|
||||
if field in json_fields:
|
||||
json_data['_inherited_settings'][field] = json_fields[field]
|
||||
|
||||
new_block = system.xblock_from_json(class_, usage_id, json_data)
|
||||
if parent_xblock is not None:
|
||||
children = parent_xblock.children
|
||||
children.append(new_block)
|
||||
# trigger setter method by using top level field access
|
||||
parent_xblock.children = children
|
||||
# decache pending children field settings (Note, truly persisting at this point would break b/c
|
||||
# persistence assumes children is a list of ids not actual xblocks)
|
||||
parent_xblock.save()
|
||||
return new_block
|
||||
|
||||
|
||||
123
cms/djangoapps/contentstore/tests/test_import_nostatic.py
Normal file
123
cms/djangoapps/contentstore/tests/test_import_nostatic.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#pylint: disable=E1101
|
||||
'''
|
||||
Tests for importing with no static
|
||||
'''
|
||||
|
||||
from django.test.client import Client
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
from path import path
|
||||
import copy
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
|
||||
class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests that rely on the toy and test_import_course courses.
|
||||
NOTE: refactor using CourseFactory so they do not.
|
||||
"""
|
||||
def setUp(self):
|
||||
|
||||
settings.MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
settings.MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
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
|
||||
|
||||
# Save the data that we've just changed to the db.
|
||||
self.user.save()
|
||||
|
||||
self.client = Client()
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
def load_test_import_course(self):
|
||||
'''
|
||||
Load the standard course used to test imports (for do_import_static=False behavior).
|
||||
'''
|
||||
content_store = contentstore()
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['test_import_course'], static_content_store=content_store, do_import_static=False, verbose=True)
|
||||
course_location = CourseDescriptor.id_to_location('edX/test_import_course/2012_Fall')
|
||||
course = module_store.get_item(course_location)
|
||||
self.assertIsNotNone(course)
|
||||
|
||||
return module_store, content_store, course, course_location
|
||||
|
||||
def test_static_import(self):
|
||||
'''
|
||||
Stuff in static_import should always be imported into contentstore
|
||||
'''
|
||||
_, content_store, course, course_location = self.load_test_import_course()
|
||||
|
||||
# make sure we have ONE asset in our contentstore ("should_be_imported.html")
|
||||
all_assets = content_store.get_all_content_for_course(course_location)
|
||||
print "len(all_assets)=%d" % len(all_assets)
|
||||
self.assertEqual(len(all_assets), 1)
|
||||
|
||||
content = None
|
||||
try:
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/test_import_course/asset/should_be_imported.html')
|
||||
content = content_store.find(location)
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
# make sure course.lms.static_asset_path is correct
|
||||
print "static_asset_path = {0}".format(course.lms.static_asset_path)
|
||||
self.assertEqual(course.lms.static_asset_path, 'test_import_course')
|
||||
|
||||
def test_asset_import_nostatic(self):
|
||||
'''
|
||||
This test validates that an image asset is NOT imported when do_import_static=False
|
||||
'''
|
||||
content_store = contentstore()
|
||||
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store, do_import_static=False, verbose=True)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
module_store.get_item(course_location)
|
||||
|
||||
# make sure we have NO assets in our contentstore
|
||||
all_assets = content_store.get_all_content_for_course(course_location)
|
||||
print "len(all_assets)=%d" % len(all_assets)
|
||||
self.assertEqual(len(all_assets), 0)
|
||||
|
||||
def test_no_static_link_rewrites_on_import(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'], do_import_static=False, verbose=True)
|
||||
|
||||
handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course_info', 'handouts', None]))
|
||||
self.assertIn('/static/', handouts.data)
|
||||
|
||||
handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'html', 'toyhtml', None]))
|
||||
self.assertIn('/static/', handouts.data)
|
||||
@@ -34,7 +34,7 @@ class DeleteItem(CourseTestCase):
|
||||
resp.content,
|
||||
"application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assert2XX(resp.status_code)
|
||||
|
||||
|
||||
class TestCreateItem(CourseTestCase):
|
||||
|
||||
@@ -6,7 +6,7 @@ from .utils import CourseTestCase
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.core.urlresolvers import reverse
|
||||
from auth.authz import get_course_groupname_for_role
|
||||
from student.views import is_enrolled_in_course
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
|
||||
class UsersTestCase(CourseTestCase):
|
||||
@@ -372,13 +372,13 @@ class UsersTestCase(CourseTestCase):
|
||||
def assert_not_enrolled(self):
|
||||
""" Asserts that self.ext_user is not enrolled in self.course. """
|
||||
self.assertFalse(
|
||||
is_enrolled_in_course(self.ext_user, self.course.location.course_id),
|
||||
CourseEnrollment.is_enrolled(self.ext_user, self.course.location.course_id),
|
||||
'Did not expect ext_user to be enrolled in course'
|
||||
)
|
||||
|
||||
def assert_enrolled(self):
|
||||
""" Asserts that self.ext_user is enrolled in self.course. """
|
||||
self.assertTrue(
|
||||
is_enrolled_in_course(self.ext_user, self.course.location.course_id),
|
||||
CourseEnrollment.is_enrolled(self.ext_user, self.course.location.course_id),
|
||||
'User ext_user should have been enrolled in the course'
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import copy
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
class LMSLinksTestCase(TestCase):
|
||||
@@ -56,21 +55,28 @@ class LMSLinksTestCase(TestCase):
|
||||
def get_about_page_link(self):
|
||||
""" create mock course and return the about page link """
|
||||
location = 'i4x', 'mitX', '101', 'course', 'test'
|
||||
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
|
||||
return utils.get_lms_link_for_about_page(location)
|
||||
|
||||
def lms_link_test(self):
|
||||
""" Tests get_lms_link_for_item. """
|
||||
location = 'i4x', 'mitX', '101', 'vertical', 'contacting_us'
|
||||
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
|
||||
link = utils.get_lms_link_for_item(location, False)
|
||||
link = utils.get_lms_link_for_item(location, False, "mitX/101/test")
|
||||
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
|
||||
link = utils.get_lms_link_for_item(location, True)
|
||||
link = utils.get_lms_link_for_item(location, True, "mitX/101/test")
|
||||
self.assertEquals(
|
||||
link,
|
||||
"//preview/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us"
|
||||
)
|
||||
|
||||
# If no course_id is passed in, it is obtained from the location. This is the case for
|
||||
# Studio dashboard.
|
||||
location = 'i4x', 'mitX', '101', 'course', 'test'
|
||||
link = utils.get_lms_link_for_item(location)
|
||||
self.assertEquals(
|
||||
link,
|
||||
"//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/course/test"
|
||||
)
|
||||
|
||||
|
||||
class ExtraPanelTabTestCase(TestCase):
|
||||
""" Tests adding and removing extra course tabs. """
|
||||
@@ -146,3 +152,12 @@ class ExtraPanelTabTestCase(TestCase):
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(actual_tabs, expected_tabs)
|
||||
|
||||
|
||||
class CourseImageTestCase(TestCase):
|
||||
"""Tests for course image URLs."""
|
||||
|
||||
def test_get_image_url(self):
|
||||
"""Test image URL formatting."""
|
||||
course = CourseFactory.create(org='edX', course='999')
|
||||
url = utils.course_image_url(course)
|
||||
self.assertEquals(url, '/c4x/edX/999/asset/{0}'.format(course.course_image))
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.conf import settings
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from django.core.urlresolvers import reverse
|
||||
import copy
|
||||
import logging
|
||||
@@ -89,8 +90,17 @@ def get_course_for_item(location):
|
||||
|
||||
|
||||
def get_lms_link_for_item(location, preview=False, course_id=None):
|
||||
"""
|
||||
Returns an LMS link to the course with a jump_to to the provided location.
|
||||
|
||||
:param location: the location to jump to
|
||||
:param preview: True if the preview version of LMS should be returned. Default value is false.
|
||||
:param course_id: the course_id within which the location lives. If not specified, the course_id is obtained
|
||||
by calling Location(location).course_id; note that this only works for locations representing courses
|
||||
instead of elements within courses.
|
||||
"""
|
||||
if course_id is None:
|
||||
course_id = get_course_id(location)
|
||||
course_id = Location(location).course_id
|
||||
|
||||
if settings.LMS_BASE is not None:
|
||||
if preview:
|
||||
@@ -136,7 +146,7 @@ def get_lms_link_for_about_page(location):
|
||||
if about_base is not None:
|
||||
lms_link = "//{about_base_url}/courses/{course_id}/about".format(
|
||||
about_base_url=about_base,
|
||||
course_id=get_course_id(location)
|
||||
course_id=Location(location).course_id
|
||||
)
|
||||
else:
|
||||
lms_link = None
|
||||
@@ -144,12 +154,11 @@ def get_lms_link_for_about_page(location):
|
||||
return lms_link
|
||||
|
||||
|
||||
def get_course_id(location):
|
||||
"""
|
||||
Returns the course_id from a given the location tuple.
|
||||
"""
|
||||
# TODO: These will need to be changed to point to the particular instance of this problem in the particular course
|
||||
return modulestore().get_containing_courses(Location(location))[0].id
|
||||
def course_image_url(course):
|
||||
"""Returns the image url for the course."""
|
||||
loc = course.location._replace(tag='c4x', category='asset', name=course.course_image)
|
||||
path = StaticContent.get_url_path_from_location(loc)
|
||||
return path
|
||||
|
||||
|
||||
class UnitState(object):
|
||||
|
||||
@@ -26,12 +26,16 @@ def has_access(user, location, role=STAFF_ROLE_NAME):
|
||||
There is a super-admin permissions if user.is_staff is set
|
||||
Also, since we're unifying the user database between LMS and CAS,
|
||||
I'm presuming that the course instructor (formally known as admin)
|
||||
will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR
|
||||
has all the rights that STAFF do
|
||||
will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our
|
||||
queries here as INSTRUCTOR has all the rights that STAFF do
|
||||
'''
|
||||
course_location = get_course_location_for_item(location)
|
||||
_has_access = is_user_in_course_group_role(user, course_location, role)
|
||||
# if we're not in STAFF, perhaps we're in INSTRUCTOR groups
|
||||
if not _has_access and role == STAFF_ROLE_NAME:
|
||||
_has_access = is_user_in_course_group_role(user, course_location, INSTRUCTOR_ROLE_NAME)
|
||||
_has_access = is_user_in_course_group_role(
|
||||
user,
|
||||
course_location,
|
||||
INSTRUCTOR_ROLE_NAME
|
||||
)
|
||||
return _has_access
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import tarfile
|
||||
import shutil
|
||||
import cgi
|
||||
from functools import partial
|
||||
from tempfile import mkdtemp
|
||||
from path import path
|
||||
|
||||
@@ -34,7 +35,8 @@ from .access import get_location_and_verify_access
|
||||
from util.json_request import JsonResponse
|
||||
|
||||
|
||||
__all__ = ['asset_index', 'upload_asset', 'import_course', 'generate_export_course', 'export_course']
|
||||
__all__ = ['asset_index', 'upload_asset', 'import_course',
|
||||
'generate_export_course', 'export_course']
|
||||
|
||||
|
||||
def assets_to_json_dict(assets):
|
||||
@@ -58,13 +60,14 @@ def assets_to_json_dict(assets):
|
||||
obj["thumbnail"] = thumbnail
|
||||
id_info = asset.get("_id")
|
||||
if id_info:
|
||||
obj["id"] = "/{tag}/{org}/{course}/{revision}/{category}/{name}".format(
|
||||
org=id_info.get("org", ""),
|
||||
course=id_info.get("course", ""),
|
||||
revision=id_info.get("revision", ""),
|
||||
tag=id_info.get("tag", ""),
|
||||
category=id_info.get("category", ""),
|
||||
name=id_info.get("name", ""),
|
||||
obj["id"] = "/{tag}/{org}/{course}/{revision}/{category}/{name}" \
|
||||
.format(
|
||||
org=id_info.get("org", ""),
|
||||
course=id_info.get("course", ""),
|
||||
revision=id_info.get("revision", ""),
|
||||
tag=id_info.get("tag", ""),
|
||||
category=id_info.get("category", ""),
|
||||
name=id_info.get("name", ""),
|
||||
)
|
||||
ret.append(obj)
|
||||
return ret
|
||||
@@ -132,14 +135,14 @@ def asset_index(request, org, course, name):
|
||||
@login_required
|
||||
def upload_asset(request, org, course, coursename):
|
||||
'''
|
||||
This method allows for POST uploading of files into the course asset library, which will
|
||||
be supported by GridFS in MongoDB.
|
||||
This method allows for POST uploading of files into the course asset
|
||||
library, which will be supported by GridFS in MongoDB.
|
||||
'''
|
||||
# construct a location from the passed in path
|
||||
location = get_location_and_verify_access(request, org, course, coursename)
|
||||
|
||||
# Does the course actually exist?!? Get anything from it to prove its existance
|
||||
|
||||
# Does the course actually exist?!? Get anything from it to prove its
|
||||
# existence
|
||||
try:
|
||||
modulestore().get_item(location)
|
||||
except:
|
||||
@@ -150,9 +153,10 @@ def upload_asset(request, org, course, coursename):
|
||||
if 'file' not in request.FILES:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# compute a 'filename' which is similar to the location formatting, we're using the 'filename'
|
||||
# nomenclature since we're using a FileSystem paradigm here. We're just imposing
|
||||
# the Location string formatting expectations to keep things a bit more consistent
|
||||
# compute a 'filename' which is similar to the location formatting, we're
|
||||
# using the 'filename' nomenclature since we're using a FileSystem paradigm
|
||||
# here. We're just imposing the Location string formatting expectations to
|
||||
# keep things a bit more consistent
|
||||
upload_file = request.FILES['file']
|
||||
filename = upload_file.name
|
||||
mime_type = upload_file.content_type
|
||||
@@ -160,20 +164,25 @@ def upload_asset(request, org, course, coursename):
|
||||
content_loc = StaticContent.compute_location(org, course, filename)
|
||||
|
||||
chunked = upload_file.multiple_chunks()
|
||||
sc_partial = partial(StaticContent, content_loc, filename, mime_type)
|
||||
if chunked:
|
||||
content = StaticContent(content_loc, filename, mime_type, upload_file.chunks())
|
||||
content = sc_partial(upload_file.chunks())
|
||||
temp_filepath = upload_file.temporary_file_path()
|
||||
else:
|
||||
content = StaticContent(content_loc, filename, mime_type, upload_file.read())
|
||||
content = sc_partial(upload_file.read())
|
||||
tempfile_path = None
|
||||
|
||||
thumbnail_content = None
|
||||
thumbnail_location = None
|
||||
|
||||
# first let's see if a thumbnail can be created
|
||||
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content,
|
||||
tempfile_path=None if not chunked else
|
||||
upload_file.temporary_file_path())
|
||||
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(
|
||||
content,
|
||||
tempfile_path=tempfile_path
|
||||
)
|
||||
|
||||
# delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show)
|
||||
# delete cached thumbnail even if one couldn't be created this time (else
|
||||
# the old thumbnail will continue to show)
|
||||
del_cached_content(thumbnail_location)
|
||||
# now store thumbnail location only if we could create it
|
||||
if thumbnail_content is not None:
|
||||
@@ -186,13 +195,15 @@ def upload_asset(request, org, course, coursename):
|
||||
# readback the saved content - we need the database timestamp
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'portable_url': StaticContent.get_static_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
}
|
||||
response_payload = {
|
||||
'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'portable_url': StaticContent.get_static_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location)
|
||||
if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
}
|
||||
|
||||
response = JsonResponse(response_payload)
|
||||
return response
|
||||
@@ -202,8 +213,8 @@ def upload_asset(request, org, course, coursename):
|
||||
@login_required
|
||||
def remove_asset(request, org, course, name):
|
||||
'''
|
||||
This method will perform a 'soft-delete' of an asset, which is basically to copy the asset from
|
||||
the main GridFS collection and into a Trashcan
|
||||
This method will perform a 'soft-delete' of an asset, which is basically to
|
||||
copy the asset from the main GridFS collection and into a Trashcan
|
||||
'''
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
@@ -315,6 +326,8 @@ def import_course(request, org, course, name):
|
||||
|
||||
create_all_course_groups(request.user, course_items[0].location)
|
||||
|
||||
logging.debug('created all course groups at {0}'.format(course_items[0].location))
|
||||
|
||||
return HttpResponse(json.dumps({'Status': 'OK'}))
|
||||
else:
|
||||
course_module = modulestore().get_item(location)
|
||||
@@ -346,6 +359,8 @@ def generate_export_course(request, org, course, name):
|
||||
try:
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
|
||||
except SerializationError, e:
|
||||
logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e)))
|
||||
|
||||
unit = None
|
||||
failed_item = None
|
||||
parent = None
|
||||
@@ -378,6 +393,7 @@ def generate_export_course(request, org, course, name):
|
||||
})
|
||||
})
|
||||
except Exception, e:
|
||||
logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e)))
|
||||
return render_to_response('export.html', {
|
||||
'context_course': course_module,
|
||||
'successful_import_redirect_url': '',
|
||||
|
||||
@@ -30,7 +30,8 @@ def get_checklists(request, org, course, name):
|
||||
modulestore = get_modulestore(location)
|
||||
course_module = modulestore.get_item(location)
|
||||
|
||||
# If course was created before checklists were introduced, copy them over from the template.
|
||||
# If course was created before checklists were introduced, copy them over
|
||||
# from the template.
|
||||
copied = False
|
||||
if not course_module.checklists:
|
||||
course_module.checklists = CourseDescriptor.checklists.default
|
||||
@@ -68,7 +69,8 @@ def update_checklist(request, org, course, name, checklist_index=None):
|
||||
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
|
||||
index = int(checklist_index)
|
||||
course_module.checklists[index] = json.loads(request.body)
|
||||
# seeming noop which triggers kvs to record that the metadata is not default
|
||||
# seeming noop which triggers kvs to record that the metadata is
|
||||
# not default
|
||||
course_module.checklists = course_module.checklists
|
||||
checklists, _ = expand_checklist_action_urls(course_module)
|
||||
course_module.save()
|
||||
@@ -76,10 +78,13 @@ def update_checklist(request, org, course, name, checklist_index=None):
|
||||
return JsonResponse(checklists[index])
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
"Could not save checklist state because the checklist index was out of range or unspecified.",
|
||||
content_type="text/plain")
|
||||
( "Could not save checklist state because the checklist index "
|
||||
"was out of range or unspecified."),
|
||||
content_type="text/plain"
|
||||
)
|
||||
elif request.method == 'GET':
|
||||
# In the JavaScript view initialize method, we do a fetch to get all the checklists.
|
||||
# In the JavaScript view initialize method, we do a fetch to get all
|
||||
# the checklists.
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if modified:
|
||||
course_module.save()
|
||||
|
||||
@@ -2,13 +2,15 @@ import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.http import ( HttpResponse, HttpResponseBadRequest,
|
||||
HttpResponseForbidden )
|
||||
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,
|
||||
InvalidLocationError )
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
@@ -19,8 +21,8 @@ from xblock.core import Scope
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
|
||||
from contentstore.module_info_model import get_module_info, set_module_info
|
||||
from contentstore.utils import get_modulestore, get_lms_link_for_item, \
|
||||
compute_unit_state, UnitState, get_course_for_item
|
||||
from contentstore.utils import ( get_modulestore, get_lms_link_for_item,
|
||||
compute_unit_state, UnitState, get_course_for_item )
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
@@ -49,7 +51,6 @@ NOTE_COMPONENT_TYPES = ['notes']
|
||||
ADVANCED_COMPONENT_TYPES = [
|
||||
'annotatable',
|
||||
'word_cloud',
|
||||
'videoalpha',
|
||||
'graphical_slider_tool'
|
||||
] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
@@ -73,10 +74,15 @@ def edit_subsection(request, location):
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
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)
|
||||
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
|
||||
# make sure that location references a 'sequential', otherwise return
|
||||
# BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
@@ -84,18 +90,23 @@ def edit_subsection(request, location):
|
||||
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error('Multiple (or none) parents have been found for {0}'.format(location))
|
||||
logging.error(
|
||||
'Multiple (or none) parents have been found for %',
|
||||
location
|
||||
)
|
||||
|
||||
# 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
|
||||
# remove all metadata from the generic dictionary that is presented in a
|
||||
# more normalized UI
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in item.fields
|
||||
if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings
|
||||
if field.name not in ['display_name', 'start', 'due', 'format']
|
||||
and field.scope == Scope.settings
|
||||
)
|
||||
|
||||
can_view_live = False
|
||||
@@ -106,19 +117,22 @@ def edit_subsection(request, location):
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
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),
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'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.location).graders),
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -126,7 +140,7 @@ def edit_unit(request, location):
|
||||
"""
|
||||
Display an editing page for the specified module.
|
||||
|
||||
Expects a GET request with the parameter 'id'.
|
||||
Expects a GET request with the parameter `id`.
|
||||
|
||||
id: A Location URL
|
||||
"""
|
||||
@@ -142,7 +156,10 @@ def edit_unit(request, location):
|
||||
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)
|
||||
lms_link = get_lms_link_for_item(
|
||||
item.location,
|
||||
course_id=course.location.course_id
|
||||
)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
for category in COMPONENT_TYPES:
|
||||
@@ -163,17 +180,19 @@ def edit_unit(request, location):
|
||||
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.
|
||||
# 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?
|
||||
# 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 = XModuleDescriptor.load_class(category)
|
||||
|
||||
@@ -184,13 +203,17 @@ def edit_unit(request, location):
|
||||
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
|
||||
# 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! {0}".format(course_advanced_keys))
|
||||
log.error(
|
||||
"Improper format for course advanced keys! %",
|
||||
course_advanced_keys
|
||||
)
|
||||
|
||||
components = [
|
||||
component.location.url()
|
||||
@@ -202,16 +225,20 @@ def edit_unit(request, location):
|
||||
# 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_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.location, None)
|
||||
containing_section_locs = modulestore().get_parent_locations(
|
||||
containing_subsection.location, None
|
||||
)
|
||||
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
|
||||
|
||||
# need to figure out where this item is in the list of children as the preview will need this
|
||||
# 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:
|
||||
@@ -220,15 +247,19 @@ def edit_unit(request, location):
|
||||
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE')
|
||||
|
||||
preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
|
||||
preview_lms_base=preview_lms_base,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=containing_section.location.name,
|
||||
subsection=containing_subsection.location.name,
|
||||
index=index)
|
||||
preview_lms_link = (
|
||||
'//{preview_lms_base}/courses/{org}/{course}/'
|
||||
'{course_name}/courseware/{section}/{subsection}/{index}'
|
||||
).format(
|
||||
preview_lms_base=preview_lms_base,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=containing_section.location.name,
|
||||
subsection=containing_subsection.location.name,
|
||||
index=index
|
||||
)
|
||||
|
||||
unit_state = compute_unit_state(item)
|
||||
|
||||
@@ -241,11 +272,13 @@ def edit_unit(request, location):
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None,
|
||||
'release_date': get_default_time_display(containing_subsection.lms.start)
|
||||
if containing_subsection.lms.start is not None else None,
|
||||
'section': containing_section,
|
||||
'new_unit_category': 'vertical',
|
||||
'unit_state': unit_state,
|
||||
'published_date': get_default_time_display(item.cms.published_date) if item.cms.published_date is not None else None
|
||||
'published_date': get_default_time_display(item.cms.published_date)
|
||||
if item.cms.published_date is not None else None
|
||||
})
|
||||
|
||||
|
||||
@@ -254,17 +287,21 @@ def edit_unit(request, location):
|
||||
@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.
|
||||
'''
|
||||
"""
|
||||
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':
|
||||
return JsonResponse(CourseGradingModel.get_section_grader_type(location))
|
||||
rsp = CourseGradingModel.get_section_grader_type(location)
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST))
|
||||
rsp = CourseGradingModel.update_section_grader_type(
|
||||
location, request.POST
|
||||
)
|
||||
return JsonResponse(rsp)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -277,8 +314,8 @@ def create_draft(request):
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# This clones the existing item location to a draft location (the draft is implicit,
|
||||
# because modulestore is a Draft modulestore)
|
||||
# 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)
|
||||
|
||||
return HttpResponse()
|
||||
@@ -287,7 +324,9 @@ def create_draft(request):
|
||||
@login_required
|
||||
@expect_json
|
||||
def publish_draft(request):
|
||||
"Publish a draft"
|
||||
"""
|
||||
Publish a draft
|
||||
"""
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
@@ -295,7 +334,10 @@ def publish_draft(request):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().publish(i.location, request.user.id))
|
||||
_xmodule_recurse(
|
||||
item,
|
||||
lambda i: modulestore().publish(i.location, request.user.id)
|
||||
)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
@@ -329,13 +371,24 @@ def module_info(request, module_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
|
||||
logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links))
|
||||
logging.debug('rewrite_static_links = {0} {1}'.format(
|
||||
request.GET.get('rewrite_url_links', False),
|
||||
rewrite_static_links)
|
||||
)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links))
|
||||
rsp = get_module_info(
|
||||
get_modulestore(location),
|
||||
location,
|
||||
rewrite_static_links=rewrite_static_links
|
||||
)
|
||||
elif request.method in ("POST", "PUT"):
|
||||
return JsonResponse(set_module_info(get_modulestore(location), location, request.POST))
|
||||
rsp = set_module_info(
|
||||
get_modulestore(location),
|
||||
location, request.POST
|
||||
)
|
||||
return JsonResponse(rsp)
|
||||
|
||||
@@ -44,7 +44,7 @@ from .component import (
|
||||
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
|
||||
from student.views import enroll_in_course
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
from xmodule.html_module import AboutDescriptor
|
||||
__all__ = ['course_index', 'create_new_course', 'course_info',
|
||||
@@ -82,7 +82,9 @@ def course_index(request, org, course, name):
|
||||
'context_course': course,
|
||||
'lms_link': lms_link,
|
||||
'sections': sections,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'course_graders': json.dumps(
|
||||
CourseGradingModel.fetch(course.location).graders
|
||||
),
|
||||
'parent_location': course.location,
|
||||
'new_section_category': 'chapter',
|
||||
'new_subsection_category': 'sequential',
|
||||
@@ -120,24 +122,31 @@ def create_new_course(request):
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
if existing_course is not None:
|
||||
return JsonResponse(
|
||||
{
|
||||
'ErrMsg': _('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'),
|
||||
}
|
||||
)
|
||||
return JsonResponse({
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'organization, course number, and course run. Please '
|
||||
'change either organization or course number to be '
|
||||
'unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
})
|
||||
|
||||
course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
|
||||
course_search_location = ['i4x', dest_location.org, dest_location.course,
|
||||
'course', None
|
||||
]
|
||||
courses = modulestore().get_items(course_search_location)
|
||||
if len(courses) > 0:
|
||||
return JsonResponse(
|
||||
{
|
||||
'ErrMsg': _('There is already a course defined with the same organization and course number. Please change at least one field to be unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'),
|
||||
}
|
||||
)
|
||||
return JsonResponse({
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'organization and course number. Please '
|
||||
'change at least one field to be unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
})
|
||||
|
||||
# instantiate the CourseDescriptor and then persist it
|
||||
# note: no system to pass
|
||||
@@ -145,11 +154,17 @@ def create_new_course(request):
|
||||
metadata = {}
|
||||
else:
|
||||
metadata = {'display_name': display_name}
|
||||
modulestore('direct').create_and_save_xmodule(dest_location, metadata=metadata)
|
||||
modulestore('direct').create_and_save_xmodule(
|
||||
dest_location,
|
||||
metadata=metadata
|
||||
)
|
||||
new_course = modulestore('direct').get_item(dest_location)
|
||||
|
||||
# clone a default 'about' overview module as well
|
||||
dest_about_location = dest_location.replace(category='about', name='overview')
|
||||
dest_about_location = dest_location.replace(
|
||||
category='about',
|
||||
name='overview'
|
||||
)
|
||||
overview_template = AboutDescriptor.get_template('overview.yaml')
|
||||
modulestore('direct').create_and_save_xmodule(
|
||||
dest_about_location,
|
||||
@@ -164,8 +179,9 @@ def create_new_course(request):
|
||||
# seed the forums
|
||||
seed_permissions_roles(new_course.location.course_id)
|
||||
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
enroll_in_course(request.user, new_course.location.course_id)
|
||||
# auto-enroll the course creator in the course so that "View Live" will
|
||||
# work.
|
||||
CourseEnrollment.enroll(request.user, new_course.location.course_id)
|
||||
|
||||
return JsonResponse({'id': new_course.location.url()})
|
||||
|
||||
@@ -174,7 +190,8 @@ def create_new_course(request):
|
||||
@ensure_csrf_cookie
|
||||
def course_info(request, org, course, name, provided_id=None):
|
||||
"""
|
||||
Send models and views as well as html for editing the course info to the client.
|
||||
Send models and views as well as html for editing the course info to the
|
||||
client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
@@ -189,8 +206,7 @@ def course_info(request, org, course, name, provided_id=None):
|
||||
'context_course': course_module,
|
||||
'url_base': "/" + org + "/" + course + "/",
|
||||
'course_updates': json.dumps(get_course_updates(location)),
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
|
||||
})
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() })
|
||||
|
||||
|
||||
@expect_json
|
||||
@@ -202,14 +218,13 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
restful CRUD operations on course_info updates.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
provided_id should be none if it's new (create) and a composite of the update db id + index otherwise.
|
||||
provided_id should be none if it's new (create) and a composite of the
|
||||
update db id + index otherwise.
|
||||
"""
|
||||
# ??? No way to check for access permission afaik
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
# Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
|
||||
# Possibly due to my removing the seemingly redundant pattern in urls.py
|
||||
if provided_id == '':
|
||||
provided_id = None
|
||||
|
||||
@@ -223,21 +238,27 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
try:
|
||||
return JsonResponse(delete_course_update(location, request.POST, provided_id))
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to delete",
|
||||
content_type="text/plain")
|
||||
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
|
||||
return HttpResponseBadRequest(
|
||||
"Failed to delete",
|
||||
content_type="text/plain"
|
||||
)
|
||||
# can be either and sometimes django is rewriting one to the other:
|
||||
elif request.method in ('POST', 'PUT'):
|
||||
try:
|
||||
return JsonResponse(update_course_updates(location, request.POST, provided_id))
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to save",
|
||||
content_type="text/plain")
|
||||
return HttpResponseBadRequest(
|
||||
"Failed to save",
|
||||
content_type="text/plain"
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
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
|
||||
"""
|
||||
@@ -253,7 +274,14 @@ def get_course_settings(request, org, course, name):
|
||||
"course": course,
|
||||
"name": name,
|
||||
"section": "details"}),
|
||||
'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False)
|
||||
'about_page_editable': not settings.MITX_FEATURES.get(
|
||||
'ENABLE_MKTG_SITE', False
|
||||
),
|
||||
'upload_asset_url': reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -261,7 +289,8 @@ def get_course_settings(request, org, course, name):
|
||||
@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.
|
||||
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
|
||||
"""
|
||||
@@ -281,7 +310,8 @@ def course_config_graders_page(request, org, course, name):
|
||||
@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.
|
||||
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
|
||||
"""
|
||||
@@ -301,8 +331,9 @@ def course_config_advanced_page(request, org, course, name):
|
||||
@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.
|
||||
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
|
||||
@@ -318,9 +349,15 @@ def course_settings_updates(request, org, course, name, section):
|
||||
|
||||
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)
|
||||
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.POST), encoder=CourseSettingsEncoder)
|
||||
return JsonResponse(
|
||||
manager.update_from_json(request.POST),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
|
||||
|
||||
@expect_json
|
||||
@@ -329,8 +366,9 @@ def course_settings_updates(request, org, course, name, section):
|
||||
@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.
|
||||
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
|
||||
"""
|
||||
@@ -339,13 +377,18 @@ def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return JsonResponse(CourseGradingModel.fetch_grader(Location(location), grader_index))
|
||||
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.POST))
|
||||
return JsonResponse(CourseGradingModel.update_grader_from_json(
|
||||
Location(location),
|
||||
request.POST
|
||||
))
|
||||
|
||||
|
||||
# # NB: expect_json failed on ["key", "key2"] and json payload
|
||||
@@ -354,8 +397,9 @@ def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
@ensure_csrf_cookie
|
||||
def course_advanced_updates(request, org, course, name):
|
||||
"""
|
||||
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.
|
||||
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
|
||||
"""
|
||||
@@ -364,20 +408,26 @@ def course_advanced_updates(request, org, course, name):
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(CourseMetadata.fetch(location))
|
||||
elif request.method == 'DELETE':
|
||||
return JsonResponse(CourseMetadata.delete_key(location, json.loads(request.body)))
|
||||
return JsonResponse(CourseMetadata.delete_key(
|
||||
location,
|
||||
json.loads(request.body)
|
||||
))
|
||||
else:
|
||||
# NOTE: request.POST is messed up because expect_json
|
||||
# cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
|
||||
# cloned_request.POST.copy() is creating a defective entry w/ the whole
|
||||
# payload as the key
|
||||
request_body = json.loads(request.body)
|
||||
# Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
|
||||
# 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
|
||||
# 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_body:
|
||||
# Get the course so that we can scrape current tabs
|
||||
@@ -389,19 +439,25 @@ def course_advanced_updates(request, org, course, name):
|
||||
'notes': NOTE_COMPONENT_TYPES,
|
||||
}
|
||||
|
||||
# Check to see if the user instantiated any notes or open ended components
|
||||
# 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_body[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
|
||||
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_body.update({'tabs': new_tabs})
|
||||
# Indicate that tabs should not be filtered out of the metadata
|
||||
# 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
|
||||
@@ -410,18 +466,26 @@ def course_advanced_updates(request, org, course, name):
|
||||
# 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)
|
||||
changed, new_tabs = remove_extra_panel_tab(
|
||||
tab_type, course_module
|
||||
)
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request_body.update({'tabs': new_tabs})
|
||||
# Indicate that tabs should *not* be filtered out of the metadata
|
||||
# Indicate that tabs should *not* be filtered out of
|
||||
# the metadata
|
||||
filter_tabs = False
|
||||
try:
|
||||
return JsonResponse(CourseMetadata.update_from_json(location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs))
|
||||
return JsonResponse(CourseMetadata.update_from_json(
|
||||
location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs
|
||||
))
|
||||
except (TypeError, ValueError) as err:
|
||||
return HttpResponseBadRequest("Incorrect setting format. " + str(err), content_type="text/plain")
|
||||
return HttpResponseBadRequest(
|
||||
"Incorrect setting format. " + str(err),
|
||||
content_type="text/plain"
|
||||
)
|
||||
|
||||
|
||||
class TextbookValidationError(Exception):
|
||||
@@ -498,7 +562,8 @@ def textbook_index(request, org, course, name):
|
||||
if request.is_ajax():
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(course_module.pdf_textbooks)
|
||||
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
|
||||
# can be either and sometimes django is rewriting one to the other:
|
||||
elif request.method in ('POST', 'PUT'):
|
||||
try:
|
||||
textbooks = validate_textbooks_json(request.body)
|
||||
except TextbookValidationError as err:
|
||||
@@ -517,7 +582,10 @@ def textbook_index(request, org, course, name):
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course_module.save()
|
||||
store.update_metadata(course_module.location, own_metadata(course_module))
|
||||
store.update_metadata(
|
||||
course_module.location,
|
||||
own_metadata(course_module)
|
||||
)
|
||||
return JsonResponse(course_module.pdf_textbooks)
|
||||
else:
|
||||
upload_asset_url = reverse('upload_asset', kwargs={
|
||||
@@ -599,7 +667,8 @@ def textbook_by_id(request, org, course, name, tid):
|
||||
if not textbook:
|
||||
return JsonResponse(status=404)
|
||||
return JsonResponse(textbook)
|
||||
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
|
||||
elif request.method in ('POST', 'PUT'): # can be either and sometimes
|
||||
# django is rewriting one to the other
|
||||
try:
|
||||
new_textbook = validate_textbook_json(request.body)
|
||||
except TextbookValidationError as err:
|
||||
@@ -616,7 +685,10 @@ def textbook_by_id(request, org, course, name, tid):
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course_module.save()
|
||||
store.update_metadata(course_module.location, own_metadata(course_module))
|
||||
store.update_metadata(
|
||||
course_module.location,
|
||||
own_metadata(course_module)
|
||||
)
|
||||
return JsonResponse(new_textbook, status=201)
|
||||
elif request.method == 'DELETE':
|
||||
if not textbook:
|
||||
@@ -626,5 +698,8 @@ def textbook_by_id(request, org, course, name, tid):
|
||||
new_textbooks.extend(course_module.pdf_textbooks[i + 1:])
|
||||
course_module.pdf_textbooks = new_textbooks
|
||||
course_module.save()
|
||||
store.update_metadata(course_module.location, own_metadata(course_module))
|
||||
store.update_metadata(
|
||||
course_module.location,
|
||||
own_metadata(course_module)
|
||||
)
|
||||
return JsonResponse()
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from util.json_request import expect_json
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
from ..utils import get_modulestore
|
||||
from .access import has_access
|
||||
from .requests import _xmodule_recurse
|
||||
@@ -20,6 +18,7 @@ __all__ = ['save_item', 'create_item', 'delete_item']
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def save_item(request):
|
||||
@@ -59,14 +58,12 @@ def save_item(request):
|
||||
# 'apply' the submitted metadata, so we don't end up deleting system metadata
|
||||
existing_item = modulestore().get_item(item_location)
|
||||
for metadata_key in request.POST.get('nullout', []):
|
||||
# [dhm] see comment on _get_xblock_field
|
||||
_get_xblock_field(existing_item, metadata_key).write_to(existing_item, None)
|
||||
|
||||
# update existing metadata with submitted metadata (which can be partial)
|
||||
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
|
||||
# the intent is to make it None, use the nullout field
|
||||
for metadata_key, value in request.POST.get('metadata', {}).items():
|
||||
# [dhm] see comment on _get_xblock_field
|
||||
field = _get_xblock_field(existing_item, metadata_key)
|
||||
|
||||
if value is None:
|
||||
@@ -80,35 +77,18 @@ def save_item(request):
|
||||
# commit to datastore
|
||||
store.update_metadata(item_location, own_metadata(existing_item))
|
||||
|
||||
return HttpResponse()
|
||||
return JsonResponse()
|
||||
|
||||
|
||||
# [DHM] A hack until we implement a permanent soln. Proposed perm solution is to make namespace fields also top level
|
||||
# fields in xblocks rather than requiring dereference through namespace but we'll need to consider whether there are
|
||||
# plausible use cases for distinct fields w/ same name in different namespaces on the same blocks.
|
||||
# The idea is that consumers of the xblock, and particularly the web client, shouldn't know about our internal
|
||||
# representation (namespaces as means of decorating all modules).
|
||||
# Given top-level access, the calls can simply be setattr(existing_item, field, value) ...
|
||||
# Really, this method should be elsewhere (e.g., xblock). We also need methods for has_value (v is_default)...
|
||||
def _get_xblock_field(xblock, field_name):
|
||||
"""
|
||||
A temporary function to get the xblock field either from the xblock or one of its namespaces by name.
|
||||
:param xblock:
|
||||
:param field_name:
|
||||
"""
|
||||
def find_field(fields):
|
||||
for field in fields:
|
||||
if field.name == field_name:
|
||||
return field
|
||||
|
||||
found = find_field(xblock.fields)
|
||||
if found:
|
||||
return found
|
||||
for namespace in xblock.namespaces:
|
||||
found = find_field(getattr(xblock, namespace).fields)
|
||||
if found:
|
||||
return found
|
||||
|
||||
for field in xblock.iterfields():
|
||||
if field.name == field_name:
|
||||
return field
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
@@ -139,13 +119,17 @@ def create_item(request):
|
||||
if display_name is not None:
|
||||
metadata['display_name'] = display_name
|
||||
|
||||
get_modulestore(category).create_and_save_xmodule(dest_location, definition_data=data,
|
||||
metadata=metadata, system=parent.system)
|
||||
get_modulestore(category).create_and_save_xmodule(
|
||||
dest_location,
|
||||
definition_data=data,
|
||||
metadata=metadata,
|
||||
system=parent.system,
|
||||
)
|
||||
|
||||
if category not in DETACHED_CATEGORIES:
|
||||
get_modulestore(parent.location).update_children(parent_location, parent.children + [dest_location.url()])
|
||||
|
||||
return HttpResponse(json.dumps({'id': dest_location.url()}))
|
||||
return JsonResponse({'id': dest_location.url()})
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -184,4 +168,4 @@ def delete_item(request):
|
||||
parent.children = children
|
||||
modulestore('direct').update_children(parent.location, parent.children)
|
||||
|
||||
return HttpResponse()
|
||||
return JsonResponse()
|
||||
|
||||
@@ -75,9 +75,15 @@ def preview_component(request, location):
|
||||
|
||||
component = modulestore().get_item(location)
|
||||
|
||||
component.get_html = wrap_xmodule(
|
||||
component.get_html,
|
||||
component,
|
||||
'xmodule_edit.html'
|
||||
)
|
||||
|
||||
return render_to_response('component.html', {
|
||||
'preview': get_module_previews(request, component)[0],
|
||||
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
|
||||
'preview': get_preview_html(request, component, 0),
|
||||
'editor': component.runtime.render(component, None, 'studio_view').content,
|
||||
})
|
||||
|
||||
|
||||
@@ -110,7 +116,7 @@ def preview_module_system(request, preview_id, descriptor):
|
||||
get_module=partial(load_preview_module, request, preview_id),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
|
||||
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id),
|
||||
user=request.user,
|
||||
xblock_model_data=preview_model_data,
|
||||
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
|
||||
@@ -149,10 +155,12 @@ def load_preview_module(request, preview_id, descriptor):
|
||||
"xmodule_display.html",
|
||||
)
|
||||
|
||||
# we pass a partially bogus course_id as we don't have the RUN information passed yet
|
||||
# through the CMS. Also the contentstore is also not RUN-aware at this point in time.
|
||||
module.get_html = replace_static_urls(
|
||||
module.get_html,
|
||||
getattr(module, 'data_dir', module.location.course),
|
||||
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
|
||||
course_id=module.location.org + '/' + module.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE'
|
||||
)
|
||||
|
||||
module.get_html = save_module(
|
||||
@@ -163,15 +171,10 @@ def load_preview_module(request, preview_id, descriptor):
|
||||
return module
|
||||
|
||||
|
||||
def get_module_previews(request, descriptor):
|
||||
def get_preview_html(request, descriptor, idx):
|
||||
"""
|
||||
Returns a list of preview XModule html contents. One preview is returned for each
|
||||
pair of states returned by get_sample_state() for the supplied descriptor.
|
||||
|
||||
descriptor: An XModuleDescriptor
|
||||
Returns the HTML returned by the XModule's student_view,
|
||||
specified by the descriptor and idx.
|
||||
"""
|
||||
preview_html = []
|
||||
for idx, (_instance_state, _shared_state) in enumerate(descriptor.get_sample_state()):
|
||||
module = load_preview_module(request, str(idx), descriptor)
|
||||
preview_html.append(module.get_html())
|
||||
return preview_html
|
||||
module = load_preview_module(request, str(idx), descriptor)
|
||||
return module.runtime.render(module, None, "student_view").content
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.core.context_processors import csrf
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from contentstore.utils import get_lms_link_for_item
|
||||
from util.json_request import JsonResponse
|
||||
from auth.authz import (
|
||||
@@ -23,7 +24,7 @@ from course_creators.views import (
|
||||
|
||||
from .access import has_access
|
||||
|
||||
from student.views import enroll_in_course
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -53,8 +54,7 @@ def index(request):
|
||||
'name': course.location.name,
|
||||
}),
|
||||
get_lms_link_for_item(
|
||||
course.location,
|
||||
course_id=course.location.course_id,
|
||||
course.location
|
||||
),
|
||||
course.display_org_with_default,
|
||||
course.display_number_with_default,
|
||||
@@ -62,7 +62,7 @@ def index(request):
|
||||
)
|
||||
|
||||
return render_to_response('index.html', {
|
||||
'courses': [format_course_for_view(c) for c in courses],
|
||||
'courses': [format_course_for_view(c) for c in courses if not isinstance(c, ErrorDescriptor)],
|
||||
'user': request.user,
|
||||
'request_course_creator_url': reverse('request_course_creator'),
|
||||
'course_creator_status': _get_course_creator_status(request.user),
|
||||
@@ -207,7 +207,7 @@ def course_team_user(request, org, course, name, email):
|
||||
user.groups.add(groups["instructor"])
|
||||
user.save()
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
enroll_in_course(user, location.course_id)
|
||||
CourseEnrollment.enroll(user, location.course_id)
|
||||
elif role == "staff":
|
||||
# if we're trying to downgrade a user from "instructor" to "staff",
|
||||
# make sure we have at least one other instructor in the course team.
|
||||
@@ -222,7 +222,7 @@ def course_team_user(request, org, course, name, email):
|
||||
user.groups.add(groups["staff"])
|
||||
user.save()
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
enroll_in_course(user, location.course_id)
|
||||
CourseEnrollment.enroll(user, location.course_id)
|
||||
|
||||
return JsonResponse()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ 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
|
||||
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
|
||||
@@ -23,6 +23,8 @@ class CourseDetails(object):
|
||||
self.overview = "" # html to render as the overview
|
||||
self.intro_video = None # a video pointer
|
||||
self.effort = None # int hours/week
|
||||
self.course_image_name = ""
|
||||
self.course_image_asset_path = "" # URL of the course image
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
@@ -40,6 +42,8 @@ class CourseDetails(object):
|
||||
course.end_date = descriptor.end
|
||||
course.enrollment_start = descriptor.enrollment_start
|
||||
course.enrollment_end = descriptor.enrollment_end
|
||||
course.course_image_name = descriptor.course_image
|
||||
course.course_image_asset_path = course_image_url(descriptor)
|
||||
|
||||
temploc = course_location.replace(category='about', name='syllabus')
|
||||
try:
|
||||
@@ -121,6 +125,10 @@ class CourseDetails(object):
|
||||
dirty = True
|
||||
descriptor.enrollment_end = converted
|
||||
|
||||
if 'course_image_name' in jsondict and jsondict['course_image_name'] != descriptor.course_image:
|
||||
descriptor.course_image = jsondict['course_image_name']
|
||||
dirty = True
|
||||
|
||||
if dirty:
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
@@ -173,7 +181,7 @@ class CourseDetails(object):
|
||||
# the right thing
|
||||
result = None
|
||||
if video_key:
|
||||
result = '<iframe width="560" height="315" src="http://www.youtube.com/embed/' + \
|
||||
result = '<iframe width="560" height="315" src="//www.youtube.com/embed/' + \
|
||||
video_key + '?autoplay=1&rel=0" frameborder="0" allowfullscreen=""></iframe>'
|
||||
return result
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ so that we can run the lettuce acceptance tests.
|
||||
# pylint: disable=W0401, W0614
|
||||
|
||||
from .test import *
|
||||
from lms.envs.sauce import *
|
||||
|
||||
# You need to start the server in debug mode,
|
||||
# otherwise the browser will not render the pages correctly
|
||||
@@ -17,7 +18,7 @@ DEBUG = True
|
||||
import logging
|
||||
logging.disable(logging.ERROR)
|
||||
import os
|
||||
import random
|
||||
from random import choice, randint
|
||||
|
||||
|
||||
def seed():
|
||||
@@ -75,8 +76,13 @@ DATABASES = {
|
||||
# Use the auto_auth workflow for creating users and logging them in
|
||||
MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
|
||||
|
||||
# HACK
|
||||
# Setting this flag to false causes imports to not load correctly in the lettuce python files
|
||||
# We do not yet understand why this occurs. Setting this to true is a stopgap measure
|
||||
USE_I18N = True
|
||||
|
||||
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
|
||||
INSTALLED_APPS += ('lettuce.django',)
|
||||
LETTUCE_APPS = ('contentstore',)
|
||||
LETTUCE_SERVER_PORT = random.randint(1024, 65535)
|
||||
LETTUCE_BROWSER = 'chrome'
|
||||
LETTUCE_SERVER_PORT = choice(PORTS) if SAUCE.get('SAUCE_ENABLED') else randint(1024, 65535)
|
||||
LETTUCE_BROWSER = os.environ.get('LETTUCE_BROWSER', 'chrome')
|
||||
|
||||
@@ -107,6 +107,7 @@ DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBA
|
||||
ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
|
||||
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
|
||||
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
|
||||
TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
|
||||
|
||||
COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", [])
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Longer TODO:
|
||||
|
||||
import sys
|
||||
import lms.envs.common
|
||||
from lms.envs.common import USE_TZ
|
||||
from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL
|
||||
from path import path
|
||||
|
||||
############################ FEATURE CONFIGURATION #############################
|
||||
@@ -39,9 +39,6 @@ MITX_FEATURES = {
|
||||
|
||||
'AUTH_USE_MIT_CERTIFICATES': False,
|
||||
|
||||
# do not display video when running automated acceptance tests
|
||||
'STUB_VIDEO_FOR_TESTING': False,
|
||||
|
||||
# email address for studio staff (eg to request course creation)
|
||||
'STUDIO_REQUEST_EMAIL': '',
|
||||
|
||||
@@ -204,7 +201,7 @@ STATICFILES_DIRS = [
|
||||
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
|
||||
|
||||
USE_I18N = True
|
||||
USE_I18N = False
|
||||
USE_L10N = True
|
||||
|
||||
# Localization strings (e.g. django.po) are under this directory
|
||||
@@ -247,8 +244,10 @@ PIPELINE_JS = {
|
||||
'js/models/course.js',
|
||||
'js/models/section.js', 'js/views/section.js',
|
||||
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
|
||||
'js/models/uploads.js', 'js/views/uploads.js',
|
||||
'js/models/textbook.js', 'js/views/textbook.js',
|
||||
'js/views/assets.js', 'js/utility.js'],
|
||||
'js/views/assets.js', 'js/utility.js',
|
||||
'js/models/settings/course_grading_policy.js'],
|
||||
'output_filename': 'js/cms-application.js',
|
||||
'test_order': 0
|
||||
},
|
||||
@@ -335,6 +334,9 @@ INSTALLED_APPS = (
|
||||
# Monitor the status of services
|
||||
'service_status',
|
||||
|
||||
# Testing
|
||||
'django_nose',
|
||||
|
||||
# For CMS
|
||||
'contentstore',
|
||||
'auth',
|
||||
@@ -342,7 +344,7 @@ INSTALLED_APPS = (
|
||||
'student', # misleading name due to sharing with lms
|
||||
'course_groups', # not used in cms (yet), but tests run
|
||||
|
||||
# tracking
|
||||
# Tracking
|
||||
'track',
|
||||
|
||||
# For asset pipelining
|
||||
@@ -355,7 +357,10 @@ INSTALLED_APPS = (
|
||||
'django_comment_common',
|
||||
|
||||
# for course creator table
|
||||
'django.contrib.admin'
|
||||
'django.contrib.admin',
|
||||
|
||||
# for managing course modes
|
||||
'course_modes'
|
||||
)
|
||||
|
||||
################# EDX MARKETING SITE ##################################
|
||||
|
||||
@@ -150,14 +150,13 @@ DEBUG_TOOLBAR_PANELS = (
|
||||
'debug_toolbar.panels.sql.SQLDebugPanel',
|
||||
'debug_toolbar.panels.signals.SignalDebugPanel',
|
||||
'debug_toolbar.panels.logger.LoggingPanel',
|
||||
'debug_toolbar_mongo.panel.MongoDebugPanel',
|
||||
|
||||
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
|
||||
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
|
||||
# hit twice). So you can uncomment when you need to diagnose performance
|
||||
# problems, but you shouldn't leave it on.
|
||||
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
|
||||
)
|
||||
)
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
'INTERCEPT_REDIRECTS': False
|
||||
@@ -165,7 +164,7 @@ DEBUG_TOOLBAR_CONFIG = {
|
||||
|
||||
# To see stacktraces for MongoDB queries, set this to True.
|
||||
# Stacktraces slow down page loads drastically (for pages with lots of queries).
|
||||
DEBUG_TOOLBAR_MONGO_STACKTRACES = True
|
||||
DEBUG_TOOLBAR_MONGO_STACKTRACES = False
|
||||
|
||||
# disable NPS survey in dev mode
|
||||
MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
|
||||
|
||||
31
cms/envs/dev_dbperf.py
Normal file
31
cms/envs/dev_dbperf.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
This configuration is to turn on the Django Toolbar stats for DB access stats, for performance analysis
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=W0401, W0614
|
||||
|
||||
from .dev import *
|
||||
|
||||
DEBUG_TOOLBAR_PANELS = (
|
||||
'debug_toolbar.panels.version.VersionDebugPanel',
|
||||
'debug_toolbar.panels.timer.TimerDebugPanel',
|
||||
'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel',
|
||||
'debug_toolbar.panels.headers.HeaderDebugPanel',
|
||||
'debug_toolbar.panels.request_vars.RequestVarsDebugPanel',
|
||||
'debug_toolbar.panels.sql.SQLDebugPanel',
|
||||
'debug_toolbar.panels.signals.SignalDebugPanel',
|
||||
'debug_toolbar.panels.logger.LoggingPanel',
|
||||
'debug_toolbar_mongo.panel.MongoDebugPanel'
|
||||
|
||||
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
|
||||
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
|
||||
# hit twice). So you can uncomment when you need to diagnose performance
|
||||
# problems, but you shouldn't leave it on.
|
||||
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
|
||||
)
|
||||
|
||||
# To see stacktraces for MongoDB queries, set this to True.
|
||||
# Stacktraces slow down page loads drastically (for pages with lots of queries).
|
||||
DEBUG_TOOLBAR_MONGO_STACKTRACES = True
|
||||
@@ -18,7 +18,6 @@ from path import path
|
||||
from warnings import filterwarnings
|
||||
|
||||
# Nose Test Runner
|
||||
INSTALLED_APPS += ('django_nose',)
|
||||
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
|
||||
|
||||
TEST_ROOT = path('test_root')
|
||||
|
||||
@@ -9,8 +9,11 @@ from django.core.cache import get_cache
|
||||
CACHE = get_cache('mongo_metadata_inheritance')
|
||||
for store_name in settings.MODULESTORE:
|
||||
store = modulestore(store_name)
|
||||
store.metadata_inheritance_cache_subsystem = CACHE
|
||||
store.request_cache = RequestCache.get_request_cache()
|
||||
|
||||
store.set_modulestore_configuration({
|
||||
'metadata_inheritance_cache_subsystem': CACHE,
|
||||
'request_cache': RequestCache.get_request_cache()
|
||||
})
|
||||
|
||||
modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
|
||||
store.modulestore_update_signal = modulestore_update_signal
|
||||
|
||||
24
cms/static/coffee/spec/models/settings_grading_spec.coffee
Normal file
24
cms/static/coffee/spec/models/settings_grading_spec.coffee
Normal file
@@ -0,0 +1,24 @@
|
||||
describe "CMS.Models.Settings.CourseGradingPolicy", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.Settings.CourseGradingPolicy()
|
||||
|
||||
describe "parse", ->
|
||||
it "sets a null grace period to 00:00", ->
|
||||
attrs = @model.parse(grace_period: null)
|
||||
expect(attrs.grace_period).toEqual(
|
||||
hours: 0,
|
||||
minutes: 0
|
||||
)
|
||||
|
||||
describe "parseGracePeriod", ->
|
||||
it "parses a time in HH:MM format", ->
|
||||
time = @model.parseGracePeriod("07:19")
|
||||
expect(time).toEqual(
|
||||
hours: 7,
|
||||
minutes: 19
|
||||
)
|
||||
|
||||
it "returns null on an incorrectly formatted string", ->
|
||||
expect(@model.parseGracePeriod("asdf")).toBe(null)
|
||||
expect(@model.parseGracePeriod("7:19")).toBe(null)
|
||||
expect(@model.parseGracePeriod("1000:00")).toBe(null)
|
||||
@@ -196,32 +196,3 @@ describe "CMS.Collections.ChapterSet", ->
|
||||
# try going back one
|
||||
@collection.remove(@collection.last())
|
||||
expect(@collection.nextOrder()).toEqual(2)
|
||||
|
||||
|
||||
describe "CMS.Models.FileUpload", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.FileUpload()
|
||||
|
||||
it "is unfinished by default", ->
|
||||
expect(@model.get("finished")).toBeFalsy()
|
||||
|
||||
it "is not uploading by default", ->
|
||||
expect(@model.get("uploading")).toBeFalsy()
|
||||
|
||||
it "is valid by default", ->
|
||||
expect(@model.isValid()).toBeTruthy()
|
||||
|
||||
it "is valid for PDF files", ->
|
||||
file = {"type": "application/pdf"}
|
||||
@model.set("selectedFile", file);
|
||||
expect(@model.isValid()).toBeTruthy()
|
||||
|
||||
it "is invalid for text files", ->
|
||||
file = {"type": "text/plain"}
|
||||
@model.set("selectedFile", file);
|
||||
expect(@model.isValid()).toBeFalsy()
|
||||
|
||||
it "is invalid for PNG files", ->
|
||||
file = {"type": "image/png"}
|
||||
@model.set("selectedFile", file);
|
||||
expect(@model.isValid()).toBeFalsy()
|
||||
|
||||
56
cms/static/coffee/spec/models/upload_spec.coffee
Normal file
56
cms/static/coffee/spec/models/upload_spec.coffee
Normal file
@@ -0,0 +1,56 @@
|
||||
describe "CMS.Models.FileUpload", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.FileUpload()
|
||||
|
||||
it "is unfinished by default", ->
|
||||
expect(@model.get("finished")).toBeFalsy()
|
||||
|
||||
it "is not uploading by default", ->
|
||||
expect(@model.get("uploading")).toBeFalsy()
|
||||
|
||||
it "is valid by default", ->
|
||||
expect(@model.isValid()).toBeTruthy()
|
||||
|
||||
it "is invalid for text files by default", ->
|
||||
file = {"type": "text/plain"}
|
||||
@model.set("selectedFile", file);
|
||||
expect(@model.isValid()).toBeFalsy()
|
||||
|
||||
it "is invalid for PNG files by default", ->
|
||||
file = {"type": "image/png"}
|
||||
@model.set("selectedFile", file);
|
||||
expect(@model.isValid()).toBeFalsy()
|
||||
|
||||
it "can accept a file type when explicitly set", ->
|
||||
file = {"type": "image/png"}
|
||||
@model.set("mimeTypes": ["image/png"])
|
||||
@model.set("selectedFile", file)
|
||||
expect(@model.isValid()).toBeTruthy()
|
||||
|
||||
it "can accept multiple file types", ->
|
||||
file = {"type": "image/gif"}
|
||||
@model.set("mimeTypes": ["image/png", "image/jpeg", "image/gif"])
|
||||
@model.set("selectedFile", file)
|
||||
expect(@model.isValid()).toBeTruthy()
|
||||
|
||||
describe "fileTypes", ->
|
||||
it "returns a list of the uploader's file types", ->
|
||||
@model.set('mimeTypes', ['image/png', 'application/json'])
|
||||
expect(@model.fileTypes()).toEqual(['PNG', 'JSON'])
|
||||
|
||||
describe "formatValidTypes", ->
|
||||
it "returns a map of formatted file types and extensions", ->
|
||||
@model.set('mimeTypes', ['image/png', 'image/jpeg', 'application/json'])
|
||||
formatted = @model.formatValidTypes()
|
||||
expect(formatted).toEqual(
|
||||
fileTypes: 'PNG, JPEG or JSON',
|
||||
fileExtensions: '.png, .jpeg or .json'
|
||||
)
|
||||
|
||||
it "does not format with only one mime type", ->
|
||||
@model.set('mimeTypes', ['application/pdf'])
|
||||
formatted = @model.formatValidTypes()
|
||||
expect(formatted).toEqual(
|
||||
fileTypes: 'PDF',
|
||||
fileExtensions: '.pdf'
|
||||
)
|
||||
@@ -1,3 +1,11 @@
|
||||
verifyInputType = (input, expectedType) ->
|
||||
# Some browsers (e.g. FireFox) do not support the "number"
|
||||
# input type. We can accept a "text" input instead
|
||||
# and still get acceptable behavior in the UI.
|
||||
if expectedType == 'number' and input.type != 'number'
|
||||
expectedType = 'text'
|
||||
expect(input.type).toBe(expectedType)
|
||||
|
||||
describe "Test Metadata Editor", ->
|
||||
editorTemplate = readFixtures('metadata-editor.underscore')
|
||||
numberEntryTemplate = readFixtures('metadata-number-entry.underscore')
|
||||
@@ -113,7 +121,7 @@ describe "Test Metadata Editor", ->
|
||||
|
||||
verifyEntry = (index, display_name, type) ->
|
||||
expect(childModels[index].get('display_name')).toBe(display_name)
|
||||
expect(childViews[index].type).toBe(type)
|
||||
verifyInputType(childViews[index], type)
|
||||
|
||||
verifyEntry(0, 'Display Name', 'text')
|
||||
verifyEntry(1, 'Inputs', 'number')
|
||||
@@ -164,7 +172,7 @@ describe "Test Metadata Editor", ->
|
||||
assertInputType = (view, expectedType) ->
|
||||
input = view.$el.find('.setting-input')
|
||||
expect(input.length).toEqual(1)
|
||||
expect(input[0].type).toEqual(expectedType)
|
||||
verifyInputType(input[0], expectedType)
|
||||
|
||||
assertValueInView = (view, expectedValue) ->
|
||||
expect(view.getValueFromEditor()).toEqual(expectedValue)
|
||||
|
||||
@@ -301,7 +301,7 @@ describe "CMS.Views.EditChapter", ->
|
||||
@view.render().$(".action-upload").click()
|
||||
ctorOptions = uploadSpies.constructor.mostRecentCall.args[0]
|
||||
expect(ctorOptions.model.get('title')).toMatch(/abcde/)
|
||||
expect(ctorOptions.chapter).toBe(@model)
|
||||
expect(typeof ctorOptions.onSuccess).toBe('function')
|
||||
expect(uploadSpies.show).toHaveBeenCalled()
|
||||
|
||||
it "saves content when opening upload dialog", ->
|
||||
@@ -311,113 +311,3 @@ describe "CMS.Views.EditChapter", ->
|
||||
@view.$(".action-upload").click()
|
||||
expect(@model.get("name")).toEqual("rainbows")
|
||||
expect(@model.get("asset_path")).toEqual("unicorns")
|
||||
|
||||
|
||||
describe "CMS.Views.UploadDialog", ->
|
||||
tpl = readFixtures("upload-dialog.underscore")
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "upload-dialog-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
|
||||
CMS.URL.UPLOAD_ASSET = "/upload"
|
||||
|
||||
@model = new CMS.Models.FileUpload()
|
||||
@chapter = new CMS.Models.Chapter()
|
||||
@view = new CMS.Views.UploadDialog({model: @model, chapter: @chapter})
|
||||
spyOn(@view, 'remove').andCallThrough()
|
||||
|
||||
# create mock file input, so that we aren't subject to browser restrictions
|
||||
@mockFiles = []
|
||||
mockFileInput = jasmine.createSpy('mockFileInput')
|
||||
mockFileInput.files = @mockFiles
|
||||
jqMockFileInput = jasmine.createSpyObj('jqMockFileInput', ['get', 'replaceWith'])
|
||||
jqMockFileInput.get.andReturn(mockFileInput)
|
||||
realMethod = @view.$
|
||||
spyOn(@view, "$").andCallFake (selector) ->
|
||||
if selector == "input[type=file]"
|
||||
jqMockFileInput
|
||||
else
|
||||
realMethod.apply(this, arguments)
|
||||
|
||||
afterEach ->
|
||||
delete CMS.URL.UPLOAD_ASSET
|
||||
|
||||
describe "Basic", ->
|
||||
it "should be shown by default", ->
|
||||
expect(@view.options.shown).toBeTruthy()
|
||||
|
||||
it "should render without a file selected", ->
|
||||
@view.render()
|
||||
expect(@view.$el).toContain("input[type=file]")
|
||||
expect(@view.$(".action-upload")).toHaveClass("disabled")
|
||||
|
||||
it "should render with a PDF selected", ->
|
||||
file = {name: "fake.pdf", "type": "application/pdf"}
|
||||
@mockFiles.push(file)
|
||||
@model.set("selectedFile", file)
|
||||
@view.render()
|
||||
expect(@view.$el).toContain("input[type=file]")
|
||||
expect(@view.$el).not.toContain("#upload_error")
|
||||
expect(@view.$(".action-upload")).not.toHaveClass("disabled")
|
||||
|
||||
it "should render an error with an invalid file type selected", ->
|
||||
file = {name: "fake.png", "type": "image/png"}
|
||||
@mockFiles.push(file)
|
||||
@model.set("selectedFile", file)
|
||||
@view.render()
|
||||
expect(@view.$el).toContain("input[type=file]")
|
||||
expect(@view.$el).toContain("#upload_error")
|
||||
expect(@view.$(".action-upload")).toHaveClass("disabled")
|
||||
|
||||
|
||||
it "adds body class on show()", ->
|
||||
@view.show()
|
||||
expect(@view.options.shown).toBeTruthy()
|
||||
# can't test: this blows up the spec runner
|
||||
# expect($("body")).toHaveClass("dialog-is-shown")
|
||||
|
||||
it "removes body class on hide()", ->
|
||||
@view.hide()
|
||||
expect(@view.options.shown).toBeFalsy()
|
||||
# can't test: this blows up the spec runner
|
||||
# expect($("body")).not.toHaveClass("dialog-is-shown")
|
||||
|
||||
describe "Uploads", ->
|
||||
beforeEach ->
|
||||
@requests = requests = []
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
@xhr.onCreate = (xhr) -> requests.push(xhr)
|
||||
@clock = sinon.useFakeTimers()
|
||||
|
||||
afterEach ->
|
||||
@xhr.restore()
|
||||
@clock.restore()
|
||||
|
||||
it "can upload correctly", ->
|
||||
@view.upload()
|
||||
expect(@model.get("uploading")).toBeTruthy()
|
||||
expect(@requests.length).toEqual(1)
|
||||
request = @requests[0]
|
||||
expect(request.url).toEqual("/upload")
|
||||
expect(request.method).toEqual("POST")
|
||||
|
||||
request.respond(200, {"Content-Type": "application/json"},
|
||||
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
|
||||
expect(@model.get("uploading")).toBeFalsy()
|
||||
expect(@model.get("finished")).toBeTruthy()
|
||||
expect(@chapter.get("name")).toEqual("starfish")
|
||||
expect(@chapter.get("asset_path")).toEqual("/uploaded/starfish.pdf")
|
||||
|
||||
it "can handle upload errors", ->
|
||||
@view.upload()
|
||||
@requests[0].respond(500)
|
||||
expect(@model.get("title")).toMatch(/error/)
|
||||
expect(@view.remove).not.toHaveBeenCalled()
|
||||
|
||||
it "removes itself after two seconds on successful upload", ->
|
||||
@view.upload()
|
||||
@requests[0].respond(200, {"Content-Type": "application/json"},
|
||||
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
|
||||
expect(@view.remove).not.toHaveBeenCalled()
|
||||
@clock.tick(2001)
|
||||
expect(@view.remove).toHaveBeenCalled()
|
||||
|
||||
120
cms/static/coffee/spec/views/upload_spec.coffee
Normal file
120
cms/static/coffee/spec/views/upload_spec.coffee
Normal file
@@ -0,0 +1,120 @@
|
||||
feedbackTpl = readFixtures('system-feedback.underscore')
|
||||
|
||||
describe "CMS.Views.UploadDialog", ->
|
||||
tpl = readFixtures("upload-dialog.underscore")
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<script>", {id: "upload-dialog-tpl", type: "text/template"}).text(tpl))
|
||||
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
|
||||
CMS.URL.UPLOAD_ASSET = "/upload"
|
||||
|
||||
@model = new CMS.Models.FileUpload(
|
||||
mimeTypes: ['application/pdf']
|
||||
)
|
||||
@chapter = new CMS.Models.Chapter()
|
||||
@view = new CMS.Views.UploadDialog(
|
||||
model: @model,
|
||||
onSuccess: (response) =>
|
||||
options = {}
|
||||
if !@chapter.get('name')
|
||||
options.name = response.displayname
|
||||
options.asset_path = response.url
|
||||
@chapter.set(options)
|
||||
)
|
||||
spyOn(@view, 'remove').andCallThrough()
|
||||
|
||||
# create mock file input, so that we aren't subject to browser restrictions
|
||||
@mockFiles = []
|
||||
mockFileInput = jasmine.createSpy('mockFileInput')
|
||||
mockFileInput.files = @mockFiles
|
||||
jqMockFileInput = jasmine.createSpyObj('jqMockFileInput', ['get', 'replaceWith'])
|
||||
jqMockFileInput.get.andReturn(mockFileInput)
|
||||
realMethod = @view.$
|
||||
spyOn(@view, "$").andCallFake (selector) ->
|
||||
if selector == "input[type=file]"
|
||||
jqMockFileInput
|
||||
else
|
||||
realMethod.apply(this, arguments)
|
||||
|
||||
afterEach ->
|
||||
delete CMS.URL.UPLOAD_ASSET
|
||||
|
||||
describe "Basic", ->
|
||||
it "should be shown by default", ->
|
||||
expect(@view.options.shown).toBeTruthy()
|
||||
|
||||
it "should render without a file selected", ->
|
||||
@view.render()
|
||||
expect(@view.$el).toContain("input[type=file]")
|
||||
expect(@view.$(".action-upload")).toHaveClass("disabled")
|
||||
|
||||
it "should render with a PDF selected", ->
|
||||
file = {name: "fake.pdf", "type": "application/pdf"}
|
||||
@mockFiles.push(file)
|
||||
@model.set("selectedFile", file)
|
||||
@view.render()
|
||||
expect(@view.$el).toContain("input[type=file]")
|
||||
expect(@view.$el).not.toContain("#upload_error")
|
||||
expect(@view.$(".action-upload")).not.toHaveClass("disabled")
|
||||
|
||||
it "should render an error with an invalid file type selected", ->
|
||||
file = {name: "fake.png", "type": "image/png"}
|
||||
@mockFiles.push(file)
|
||||
@model.set("selectedFile", file)
|
||||
@view.render()
|
||||
expect(@view.$el).toContain("input[type=file]")
|
||||
expect(@view.$el).toContain("#upload_error")
|
||||
expect(@view.$(".action-upload")).toHaveClass("disabled")
|
||||
|
||||
|
||||
it "adds body class on show()", ->
|
||||
@view.show()
|
||||
expect(@view.options.shown).toBeTruthy()
|
||||
# can't test: this blows up the spec runner
|
||||
# expect($("body")).toHaveClass("dialog-is-shown")
|
||||
|
||||
it "removes body class on hide()", ->
|
||||
@view.hide()
|
||||
expect(@view.options.shown).toBeFalsy()
|
||||
# can't test: this blows up the spec runner
|
||||
# expect($("body")).not.toHaveClass("dialog-is-shown")
|
||||
|
||||
describe "Uploads", ->
|
||||
beforeEach ->
|
||||
@requests = requests = []
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
@xhr.onCreate = (xhr) -> requests.push(xhr)
|
||||
@clock = sinon.useFakeTimers()
|
||||
|
||||
afterEach ->
|
||||
@xhr.restore()
|
||||
@clock.restore()
|
||||
|
||||
it "can upload correctly", ->
|
||||
@view.upload()
|
||||
expect(@model.get("uploading")).toBeTruthy()
|
||||
expect(@requests.length).toEqual(1)
|
||||
request = @requests[0]
|
||||
expect(request.url).toEqual("/upload")
|
||||
expect(request.method).toEqual("POST")
|
||||
|
||||
request.respond(200, {"Content-Type": "application/json"},
|
||||
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
|
||||
expect(@model.get("uploading")).toBeFalsy()
|
||||
expect(@model.get("finished")).toBeTruthy()
|
||||
expect(@chapter.get("name")).toEqual("starfish")
|
||||
expect(@chapter.get("asset_path")).toEqual("/uploaded/starfish.pdf")
|
||||
|
||||
it "can handle upload errors", ->
|
||||
@view.upload()
|
||||
@requests[0].respond(500)
|
||||
expect(@model.get("title")).toMatch(/error/)
|
||||
expect(@view.remove).not.toHaveBeenCalled()
|
||||
|
||||
it "removes itself after two seconds on successful upload", ->
|
||||
@view.upload()
|
||||
@requests[0].respond(200, {"Content-Type": "application/json"},
|
||||
'{"displayname": "starfish", "url": "/uploaded/starfish.pdf"}')
|
||||
expect(@view.remove).not.toHaveBeenCalled()
|
||||
@clock.tick(2001)
|
||||
expect(@view.remove).toHaveBeenCalled()
|
||||
@@ -120,6 +120,7 @@ class CMS.Views.UnitEdit extends Backbone.View
|
||||
@model.save()
|
||||
|
||||
deleteComponent: (event) =>
|
||||
event.preventDefault()
|
||||
msg = new CMS.Views.Prompt.Warning(
|
||||
title: gettext('Delete this component?'),
|
||||
message: gettext('Deleting this component is permanent and cannot be undone.'),
|
||||
|
||||
@@ -605,81 +605,118 @@ function cancelNewSection(e) {
|
||||
function addNewCourse(e) {
|
||||
e.preventDefault();
|
||||
$('.new-course-button').addClass('is-disabled');
|
||||
$('.new-course-save').addClass('is-disabled');
|
||||
var $newCourse = $('.wrapper-create-course').addClass('is-shown');
|
||||
var $cancelButton = $newCourse.find('.new-course-cancel');
|
||||
$newCourse.find('.new-course-name').focus().select();
|
||||
$newCourse.find('form').bind('submit', saveNewCourse);
|
||||
var $courseName = $('.new-course-name');
|
||||
$courseName.focus().select();
|
||||
$('.new-course-save').on('click', saveNewCourse);
|
||||
$cancelButton.bind('click', cancelNewCourse);
|
||||
$body.bind('keyup', {
|
||||
$cancelButton: $cancelButton
|
||||
}, checkForCancel);
|
||||
|
||||
// Check that a course (org, number, run) doesn't use any special characters
|
||||
var validateCourseItemEncoding = function(item) {
|
||||
var required = validateRequiredField(item);
|
||||
if(required) {
|
||||
return required;
|
||||
}
|
||||
if(item !== encodeURIComponent(item)) {
|
||||
return gettext('Please do not use any spaces or special characters in this field.');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Ensure that all items are less than 80 characters.
|
||||
var validateTotalCourseItemsLength = function() {
|
||||
var totalLength = _.reduce(
|
||||
['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
|
||||
function(sum, ele) {
|
||||
return sum + $(ele).val().length;
|
||||
}, 0
|
||||
);
|
||||
if(totalLength > 80) {
|
||||
$('.wrap-error').addClass('is-shown');
|
||||
$('#course_creation_error').html('<p>' + gettext('Course fields must have a combined length of no more than 80 characters.') + '</p>');
|
||||
$('.new-course-save').addClass('is-disabled');
|
||||
}
|
||||
else {
|
||||
$('.wrap-error').removeClass('is-shown');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle validation asynchronously
|
||||
_.each(
|
||||
['.new-course-org', '.new-course-number', '.new-course-run'],
|
||||
function(ele) {
|
||||
var $ele = $(ele);
|
||||
$ele.on('keyup', function(event) {
|
||||
// Don't bother showing "required field" error when
|
||||
// the user tabs into a new field; this is distracting
|
||||
// and unnecessary
|
||||
if(event.keyCode === 9) {
|
||||
return;
|
||||
}
|
||||
var error = validateCourseItemEncoding($ele.val());
|
||||
setNewCourseFieldInErr($ele.parent('li'), error);
|
||||
validateTotalCourseItemsLength();
|
||||
});
|
||||
}
|
||||
);
|
||||
var $name = $('.new-course-name');
|
||||
$name.on('keyup', function() {
|
||||
var error = validateRequiredField($name.val());
|
||||
setNewCourseFieldInErr($name.parent('li'), error);
|
||||
validateTotalCourseItemsLength();
|
||||
});
|
||||
}
|
||||
|
||||
function validateRequiredField(msg) {
|
||||
return msg.length === 0 ? gettext('Required field.') : '';
|
||||
}
|
||||
|
||||
function setNewCourseFieldInErr(el, msg) {
|
||||
if(msg) {
|
||||
el.addClass('error');
|
||||
el.children('span.tip-error').addClass('is-showing').removeClass('is-hiding').text(msg);
|
||||
$('.new-course-save').addClass('is-disabled');
|
||||
}
|
||||
else {
|
||||
el.removeClass('error');
|
||||
el.children('span.tip-error').addClass('is-hiding').removeClass('is-showing');
|
||||
// One "error" div is always present, but hidden or shown
|
||||
if($('.error').length === 1) {
|
||||
$('.new-course-save').removeClass('is-disabled');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function saveNewCourse(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// One final check for empty values
|
||||
var errors = _.reduce(
|
||||
['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
|
||||
function(acc, ele) {
|
||||
var $ele = $(ele);
|
||||
var error = validateRequiredField($ele.val());
|
||||
setNewCourseFieldInErr($ele.parent('li'), error);
|
||||
return error ? true : acc;
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if(errors) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $newCourseForm = $(this).closest('#create-course-form');
|
||||
var display_name = $newCourseForm.find('.new-course-name').val();
|
||||
var org = $newCourseForm.find('.new-course-org').val();
|
||||
var number = $newCourseForm.find('.new-course-number').val();
|
||||
var run = $newCourseForm.find('.new-course-run').val();
|
||||
|
||||
var required_field_text = gettext('Required field');
|
||||
|
||||
var display_name_errMsg = (display_name === '') ? required_field_text : null;
|
||||
var org_errMsg = (org === '') ? required_field_text : null;
|
||||
var number_errMsg = (number === '') ? required_field_text : null;
|
||||
var run_errMsg = (run === '') ? required_field_text : null;
|
||||
|
||||
var bInErr = (display_name_errMsg || org_errMsg || number_errMsg || run_errMsg);
|
||||
|
||||
// check for suitable encoding
|
||||
if (!bInErr) {
|
||||
var encoding_errMsg = gettext('Please do not use any spaces or special characters in this field.');
|
||||
|
||||
if (encodeURIComponent(org) != org)
|
||||
org_errMsg = encoding_errMsg;
|
||||
if (encodeURIComponent(number) != number)
|
||||
number_errMsg = encoding_errMsg;
|
||||
if (encodeURIComponent(run) != run)
|
||||
run_errMsg = encoding_errMsg;
|
||||
|
||||
bInErr = (org_errMsg || number_errMsg || run_errMsg);
|
||||
}
|
||||
|
||||
var header_err_msg = (bInErr) ? gettext('Please correct the fields below.') : null;
|
||||
|
||||
var setNewCourseErrMsgs = function(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg) {
|
||||
if (header_err_msg) {
|
||||
$('.wrapper-create-course').addClass('has-errors');
|
||||
$('.wrap-error').addClass('is-shown');
|
||||
$('#course_creation_error').html('<p>' + header_err_msg + '</p>');
|
||||
} else {
|
||||
$('.wrap-error').removeClass('is-shown');
|
||||
$('#course_creation_error').html('');
|
||||
}
|
||||
|
||||
var setNewCourseFieldInErr = function(el, msg) {
|
||||
el.children('.tip-error').remove();
|
||||
if (msg !== null && msg !== '') {
|
||||
el.addClass('error');
|
||||
el.append('<span class="tip tip-error">' + msg + '</span>');
|
||||
} else {
|
||||
el.removeClass('error');
|
||||
}
|
||||
};
|
||||
|
||||
setNewCourseFieldInErr($('#field-course-name'), display_name_errMsg);
|
||||
setNewCourseFieldInErr($('#field-organization'), org_errMsg);
|
||||
setNewCourseFieldInErr($('#field-course-number'), number_errMsg);
|
||||
setNewCourseFieldInErr($('#field-course-run'), run_errMsg);
|
||||
};
|
||||
|
||||
setNewCourseErrMsgs(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg);
|
||||
|
||||
if (bInErr)
|
||||
return;
|
||||
|
||||
analytics.track('Created a Course', {
|
||||
'org': org,
|
||||
'number': number,
|
||||
@@ -697,9 +734,9 @@ function saveNewCourse(e) {
|
||||
if (data.id !== undefined) {
|
||||
window.location = '/' + data.id.replace(/.*:\/\//, '');
|
||||
} else if (data.ErrMsg !== undefined) {
|
||||
var orgErrMsg = (data.OrgErrMsg !== undefined) ? data.OrgErrMsg : null;
|
||||
var courseErrMsg = (data.CourseErrMsg !== undefined) ? data.CourseErrMsg : null;
|
||||
setNewCourseErrMsgs(data.ErrMsg, null, orgErrMsg, courseErrMsg, null);
|
||||
$('.wrap-error').addClass('is-shown');
|
||||
$('#course_creation_error').html('<p>' + data.ErrMsg + '</p>');
|
||||
$('.new-course-save').addClass('is-disabled');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -709,6 +746,16 @@ function cancelNewCourse(e) {
|
||||
e.preventDefault();
|
||||
$('.new-course-button').removeClass('is-disabled');
|
||||
$('.wrapper-create-course').removeClass('is-shown');
|
||||
// Clear out existing fields and errors
|
||||
_.each(
|
||||
['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
|
||||
function(field) {
|
||||
$(field).val('');
|
||||
}
|
||||
);
|
||||
$('#course_creation_error').html('');
|
||||
$('.wrap-error').removeClass('is-shown');
|
||||
$('.new-course-save').off('click');
|
||||
}
|
||||
|
||||
function addNewSubsection(e) {
|
||||
|
||||
@@ -10,7 +10,9 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
|
||||
syllabus: null,
|
||||
overview: "",
|
||||
intro_video: null,
|
||||
effort: null // an int or null
|
||||
effort: null, // an int or null,
|
||||
course_image_name: '', // the filename
|
||||
course_image_asset_path: '' // the full URL (/c4x/org/course/num/asset/filename)
|
||||
},
|
||||
|
||||
// When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset)
|
||||
@@ -75,7 +77,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
|
||||
return this.videosourceSample();
|
||||
},
|
||||
videosourceSample : function() {
|
||||
if (this.has('intro_video')) return "http://www.youtube.com/embed/" + this.get('intro_video');
|
||||
if (this.has('intro_video')) return "//www.youtube.com/embed/" + this.get('intro_video');
|
||||
else return "";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,6 +24,14 @@ CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({
|
||||
}
|
||||
attributes.graders = graderCollection;
|
||||
}
|
||||
// If grace period is unset or equal to 00:00 on the server,
|
||||
// it's received as null
|
||||
if (attributes['grace_period'] === null) {
|
||||
attributes.grace_period = {
|
||||
hours: 0,
|
||||
minutes: 0
|
||||
}
|
||||
}
|
||||
return attributes;
|
||||
},
|
||||
url : function() {
|
||||
@@ -44,8 +52,25 @@ CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({
|
||||
|
||||
return newDate;
|
||||
},
|
||||
dateToGracePeriod : function(date) {
|
||||
return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() };
|
||||
parseGracePeriod : function(grace_period) {
|
||||
// Enforce hours:minutes format
|
||||
if(!/^\d{2,3}:\d{2}$/.test(grace_period)) {
|
||||
return null;
|
||||
}
|
||||
var pieces = grace_period.split(/:/);
|
||||
return {
|
||||
hours: parseInt(pieces[0], 10),
|
||||
minutes: parseInt(pieces[1], 10)
|
||||
}
|
||||
},
|
||||
validate : function(attrs) {
|
||||
if(_.has(attrs, 'grace_period')) {
|
||||
if(attrs['grace_period'] === null) {
|
||||
return {
|
||||
'grace_period': gettext('Grace period must be specified in HH:MM format.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -155,24 +155,4 @@ CMS.Collections.ChapterSet = Backbone.Collection.extend({
|
||||
return this.length === 0 || this.every(function(m) { return m.isEmpty(); });
|
||||
}
|
||||
});
|
||||
CMS.Models.FileUpload = Backbone.Model.extend({
|
||||
defaults: {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"selectedFile": null,
|
||||
"uploading": false,
|
||||
"uploadedBytes": 0,
|
||||
"totalBytes": 0,
|
||||
"finished": false
|
||||
},
|
||||
// NOTE: validation functions should return non-internationalized error
|
||||
// messages. The messages will be passed through gettext in the template.
|
||||
validate: function(attrs, options) {
|
||||
if(attrs.selectedFile && attrs.selectedFile.type !== "application/pdf") {
|
||||
return {
|
||||
message: "Only PDF files can be uploaded. Please select a file ending in .pdf to upload.",
|
||||
attributes: {selectedFile: true}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
59
cms/static/js/models/uploads.js
Normal file
59
cms/static/js/models/uploads.js
Normal file
@@ -0,0 +1,59 @@
|
||||
CMS.Models.FileUpload = Backbone.Model.extend({
|
||||
defaults: {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"selectedFile": null,
|
||||
"uploading": false,
|
||||
"uploadedBytes": 0,
|
||||
"totalBytes": 0,
|
||||
"finished": false,
|
||||
"mimeTypes": []
|
||||
},
|
||||
validate: function(attrs, options) {
|
||||
if(attrs.selectedFile && !_.contains(this.attributes.mimeTypes, attrs.selectedFile.type)) {
|
||||
return {
|
||||
message: _.template(
|
||||
gettext("Only <%= fileTypes %> files can be uploaded. Please select a file ending in <%= fileExtensions %> to upload."),
|
||||
this.formatValidTypes()
|
||||
),
|
||||
attributes: {selectedFile: true}
|
||||
};
|
||||
}
|
||||
},
|
||||
// Return a list of this uploader's valid file types
|
||||
fileTypes: function() {
|
||||
return _.map(
|
||||
this.attributes.mimeTypes,
|
||||
function(type) {
|
||||
return type.split('/')[1].toUpperCase();
|
||||
}
|
||||
);
|
||||
},
|
||||
// Return strings for the valid file types and extensions this
|
||||
// uploader accepts, formatted as natural language
|
||||
formatValidTypes: function() {
|
||||
if(this.attributes.mimeTypes.length === 1) {
|
||||
return {
|
||||
fileTypes: this.fileTypes()[0],
|
||||
fileExtensions: '.' + this.fileTypes()[0].toLowerCase()
|
||||
};
|
||||
}
|
||||
var or = gettext('or');
|
||||
var formatTypes = function(types) {
|
||||
return _.template('<%= initial %> <%= or %> <%= last %>', {
|
||||
initial: _.initial(types).join(', '),
|
||||
or: or,
|
||||
last: _.last(types)
|
||||
});
|
||||
};
|
||||
return {
|
||||
fileTypes: formatTypes(this.fileTypes()),
|
||||
fileExtensions: formatTypes(
|
||||
_.map(this.fileTypes(),
|
||||
function(type) {
|
||||
return '.' + type.toLowerCase();
|
||||
})
|
||||
)
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -13,8 +13,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
'mouseover #timezone' : "updateTime",
|
||||
// would love to move to a general superclass, but event hashes don't inherit in backbone :-(
|
||||
'focus :input' : "inputFocus",
|
||||
'blur :input' : "inputUnfocus"
|
||||
|
||||
'blur :input' : "inputUnfocus",
|
||||
'click .action-upload-image': "uploadImage"
|
||||
},
|
||||
|
||||
initialize : function() {
|
||||
@@ -25,6 +25,14 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
this.$el.find("#course-number").val(this.model.get('location').get('course'));
|
||||
this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
|
||||
|
||||
// Avoid showing broken image on mistyped/nonexistent image
|
||||
this.$el.find('img.course-image').error(function() {
|
||||
$(this).hide();
|
||||
});
|
||||
this.$el.find('img.course-image').load(function() {
|
||||
$(this).show();
|
||||
});
|
||||
|
||||
var dateIntrospect = new Date();
|
||||
this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")");
|
||||
|
||||
@@ -51,6 +59,10 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
|
||||
this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort'));
|
||||
|
||||
var imageURL = this.model.get('course_image_asset_path');
|
||||
this.$el.find('#course-image-url').val(imageURL)
|
||||
this.$el.find('#course-image').attr('src', imageURL);
|
||||
|
||||
return this;
|
||||
},
|
||||
fieldToSelectorMap : {
|
||||
@@ -60,7 +72,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
'enrollment_end' : 'enrollment-end',
|
||||
'overview' : 'course-overview',
|
||||
'intro_video' : 'course-introduction-video',
|
||||
'effort' : "course-effort"
|
||||
'effort' : "course-effort",
|
||||
'course_image_asset_path': 'course-image-url'
|
||||
},
|
||||
|
||||
updateTime : function(e) {
|
||||
@@ -121,6 +134,17 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
|
||||
updateModel: function(event) {
|
||||
switch (event.currentTarget.id) {
|
||||
case 'course-image-url':
|
||||
this.setField(event);
|
||||
var url = $(event.currentTarget).val();
|
||||
var image_name = _.last(url.split('/'));
|
||||
this.model.set('course_image_name', image_name);
|
||||
// Wait to set the image src until the user stops typing
|
||||
clearTimeout(this.imageTimer);
|
||||
this.imageTimer = setTimeout(function() {
|
||||
$('#course-image').attr('src', $(event.currentTarget).val());
|
||||
}, 1000);
|
||||
break;
|
||||
case 'course-effort':
|
||||
this.setField(event);
|
||||
break;
|
||||
@@ -216,6 +240,29 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
this.save_message,
|
||||
_.bind(this.saveView, this),
|
||||
_.bind(this.revertView, this));
|
||||
},
|
||||
|
||||
uploadImage: function(event) {
|
||||
event.preventDefault();
|
||||
var upload = new CMS.Models.FileUpload({
|
||||
title: gettext("Upload your course image."),
|
||||
message: gettext("Files must be in JPEG or PNG format."),
|
||||
mimeTypes: ['image/jpeg', 'image/png']
|
||||
});
|
||||
var self = this;
|
||||
var modal = new CMS.Views.UploadDialog({
|
||||
model: upload,
|
||||
onSuccess: function(response) {
|
||||
var options = {
|
||||
'course_image_name': response.displayname,
|
||||
'course_image_asset_path': response.url
|
||||
}
|
||||
self.model.set(options);
|
||||
self.render();
|
||||
$('#course-image').attr('src', self.model.get('course_image_asset_path'))
|
||||
}
|
||||
});
|
||||
$('.wrapper-view').after(modal.show().el);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
// Leaving change in as fallback for older browsers
|
||||
"change input" : "updateModel",
|
||||
"change textarea" : "updateModel",
|
||||
"change span[contenteditable=true]" : "updateDesignation",
|
||||
"input span[contenteditable=true]" : "updateDesignation",
|
||||
"click .settings-extra header" : "showSettingsExtras",
|
||||
"click .new-grade-button" : "addNewGrade",
|
||||
"click .remove-button" : "removeGrade",
|
||||
@@ -20,7 +20,7 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
initialize : function() {
|
||||
// load template for grading view
|
||||
var self = this;
|
||||
this.gradeCutoffTemplate = _.template('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable>' +
|
||||
this.gradeCutoffTemplate = _.template('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable="true">' +
|
||||
'<%= descriptor %>' +
|
||||
'</span><span class="range"></span>' +
|
||||
'<% if (removable) {%><a href="#" class="remove-button">remove</a><% ;} %>' +
|
||||
@@ -28,9 +28,6 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
|
||||
this.setupCutoffs();
|
||||
|
||||
// Instrument grace period
|
||||
this.$el.find('#course-grading-graceperiod').timepicker();
|
||||
|
||||
// instantiates an editor template for each update in the collection
|
||||
// Because this calls render, put it after everything which render may depend upon to prevent race condition.
|
||||
window.templateLoader.loadRemoteTemplate("course_grade_policy",
|
||||
@@ -51,6 +48,10 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
// prevent bootstrap race condition by event dispatch
|
||||
if (!this.template) return;
|
||||
|
||||
this.clearValidationErrors();
|
||||
|
||||
this.renderGracePeriod();
|
||||
|
||||
// Create and render the grading type subs
|
||||
var self = this;
|
||||
var gradelist = this.$el.find('.course-grading-assignment-list');
|
||||
@@ -87,13 +88,6 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
// render the grade cutoffs
|
||||
this.renderCutoffBar();
|
||||
|
||||
var graceEle = this.$el.find('#course-grading-graceperiod');
|
||||
graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime
|
||||
if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate());
|
||||
// remove any existing listeners to keep them from piling on b/c render gets called frequently
|
||||
graceEle.off('change', this.setGracePeriod);
|
||||
graceEle.on('change', this, this.setGracePeriod);
|
||||
|
||||
return this;
|
||||
},
|
||||
addAssignmentType : function(e) {
|
||||
@@ -103,17 +97,26 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
fieldToSelectorMap : {
|
||||
'grace_period' : 'course-grading-graceperiod'
|
||||
},
|
||||
renderGracePeriod: function() {
|
||||
var format = function(time) {
|
||||
return time >= 10 ? time.toString() : '0' + time;
|
||||
};
|
||||
var grace_period = this.model.get('grace_period');
|
||||
this.$el.find('#course-grading-graceperiod').val(
|
||||
format(grace_period.hours) + ':' + format(grace_period.minutes)
|
||||
);
|
||||
},
|
||||
setGracePeriod : function(event) {
|
||||
var self = event.data;
|
||||
self.clearValidationErrors();
|
||||
var newVal = self.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
|
||||
self.model.set('grace_period', newVal, {validate: true});
|
||||
this.clearValidationErrors();
|
||||
var newVal = this.model.parseGracePeriod($(event.currentTarget).val());
|
||||
this.model.set('grace_period', newVal, {validate: true});
|
||||
},
|
||||
updateModel : function(event) {
|
||||
if (!this.selectorToField[event.currentTarget.id]) return;
|
||||
|
||||
switch (this.selectorToField[event.currentTarget.id]) {
|
||||
case 'grace_period': // handled above
|
||||
case 'grace_period':
|
||||
this.setGracePeriod(event);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -168,9 +171,12 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
},
|
||||
this);
|
||||
// add fail which is not in data
|
||||
var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(),
|
||||
width : nextWidth, removable : false});
|
||||
$(failBar).find("span[contenteditable=true]").attr("contenteditable", false);
|
||||
var failBar = $(this.gradeCutoffTemplate({
|
||||
descriptor : this.failLabel(),
|
||||
width : nextWidth,
|
||||
removable : false
|
||||
}));
|
||||
failBar.find("span[contenteditable=true]").attr("contenteditable", false);
|
||||
gradelist.append(failBar);
|
||||
gradelist.children().last().resizable({
|
||||
handles: "e",
|
||||
|
||||
@@ -243,120 +243,21 @@ CMS.Views.EditChapter = Backbone.View.extend({
|
||||
var msg = new CMS.Models.FileUpload({
|
||||
title: _.template(gettext("Upload a new PDF to “<%= name %>”"),
|
||||
{name: section.escape('name')}),
|
||||
message: "Files must be in PDF format."
|
||||
message: "Files must be in PDF format.",
|
||||
mimeTypes: ['application/pdf']
|
||||
});
|
||||
var that = this;
|
||||
var view = new CMS.Views.UploadDialog({
|
||||
model: msg,
|
||||
onSuccess: function(response) {
|
||||
var options = {};
|
||||
if(!that.model.get('name')) {
|
||||
options.name = response.displayname;
|
||||
}
|
||||
options.asset_path = response.url;
|
||||
that.model.set(options);
|
||||
},
|
||||
});
|
||||
var view = new CMS.Views.UploadDialog({model: msg, chapter: this.model});
|
||||
$(".wrapper-view").after(view.show().el);
|
||||
}
|
||||
});
|
||||
|
||||
CMS.Views.UploadDialog = Backbone.View.extend({
|
||||
options: {
|
||||
shown: true,
|
||||
successMessageTimeout: 2000 // 2 seconds
|
||||
},
|
||||
initialize: function() {
|
||||
this.template = _.template($("#upload-dialog-tpl").text());
|
||||
this.listenTo(this.model, "change", this.render);
|
||||
},
|
||||
render: function() {
|
||||
var isValid = this.model.isValid();
|
||||
var selectedFile = this.model.get('selectedFile');
|
||||
var oldInput = this.$("input[type=file]").get(0);
|
||||
this.$el.html(this.template({
|
||||
shown: this.options.shown,
|
||||
url: CMS.URL.UPLOAD_ASSET,
|
||||
title: this.model.escape('title'),
|
||||
message: this.model.escape('message'),
|
||||
selectedFile: selectedFile,
|
||||
uploading: this.model.get('uploading'),
|
||||
uploadedBytes: this.model.get('uploadedBytes'),
|
||||
totalBytes: this.model.get('totalBytes'),
|
||||
finished: this.model.get('finished'),
|
||||
error: this.model.validationError
|
||||
}));
|
||||
// Ideally, we'd like to tell the browser to pre-populate the
|
||||
// <input type="file"> with the selectedFile if we have one -- but
|
||||
// browser security prohibits that. So instead, we'll swap out the
|
||||
// new input (that has no file selected) with the old input (that
|
||||
// already has the selectedFile selected). However, we only want to do
|
||||
// this if the selected file is valid: if it isn't, we want to render
|
||||
// a blank input to prompt the user to upload a different (valid) file.
|
||||
if (selectedFile && isValid) {
|
||||
$(oldInput).removeClass("error");
|
||||
this.$('input[type=file]').replaceWith(oldInput);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
events: {
|
||||
"change input[type=file]": "selectFile",
|
||||
"click .action-cancel": "hideAndRemove",
|
||||
"click .action-upload": "upload"
|
||||
},
|
||||
selectFile: function(e) {
|
||||
this.model.set({
|
||||
selectedFile: e.target.files[0] || null
|
||||
});
|
||||
},
|
||||
show: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.options.shown = true;
|
||||
$body.addClass('dialog-is-shown');
|
||||
return this.render();
|
||||
},
|
||||
hide: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.options.shown = false;
|
||||
$body.removeClass('dialog-is-shown');
|
||||
return this.render();
|
||||
},
|
||||
hideAndRemove: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
return this.hide().remove();
|
||||
},
|
||||
upload: function(e) {
|
||||
this.model.set('uploading', true);
|
||||
this.$("form").ajaxSubmit({
|
||||
success: _.bind(this.success, this),
|
||||
error: _.bind(this.error, this),
|
||||
uploadProgress: _.bind(this.progress, this),
|
||||
data: {
|
||||
// don't show the generic error notification; we're in a modal,
|
||||
// and we're better off modifying it instead.
|
||||
notifyOnError: false
|
||||
}
|
||||
});
|
||||
},
|
||||
progress: function(event, position, total, percentComplete) {
|
||||
this.model.set({
|
||||
"uploadedBytes": position,
|
||||
"totalBytes": total
|
||||
});
|
||||
},
|
||||
success: function(response, statusText, xhr, form) {
|
||||
this.model.set({
|
||||
uploading: false,
|
||||
finished: true
|
||||
});
|
||||
var chapter = this.options.chapter;
|
||||
if(chapter) {
|
||||
var options = {};
|
||||
if(!chapter.get("name")) {
|
||||
options.name = response.displayname;
|
||||
}
|
||||
options.asset_path = response.url;
|
||||
chapter.set(options);
|
||||
}
|
||||
var that = this;
|
||||
this.removalTimeout = setTimeout(function() {
|
||||
that.hide().remove();
|
||||
}, this.options.successMessageTimeout);
|
||||
},
|
||||
error: function() {
|
||||
this.model.set({
|
||||
"uploading": false,
|
||||
"uploadedBytes": 0,
|
||||
"title": gettext("We're sorry, there was an error")
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
105
cms/static/js/views/uploads.js
Normal file
105
cms/static/js/views/uploads.js
Normal file
@@ -0,0 +1,105 @@
|
||||
CMS.Views.UploadDialog = Backbone.View.extend({
|
||||
options: {
|
||||
shown: true,
|
||||
successMessageTimeout: 2000 // 2 seconds
|
||||
},
|
||||
initialize: function() {
|
||||
this.template = _.template($("#upload-dialog-tpl").text());
|
||||
this.listenTo(this.model, "change", this.render);
|
||||
},
|
||||
render: function() {
|
||||
var isValid = this.model.isValid();
|
||||
var selectedFile = this.model.get('selectedFile');
|
||||
var oldInput = this.$("input[type=file]").get(0);
|
||||
this.$el.html(this.template({
|
||||
shown: this.options.shown,
|
||||
url: CMS.URL.UPLOAD_ASSET,
|
||||
title: this.model.escape('title'),
|
||||
message: this.model.escape('message'),
|
||||
selectedFile: selectedFile,
|
||||
uploading: this.model.get('uploading'),
|
||||
uploadedBytes: this.model.get('uploadedBytes'),
|
||||
totalBytes: this.model.get('totalBytes'),
|
||||
finished: this.model.get('finished'),
|
||||
error: this.model.validationError
|
||||
}));
|
||||
// Ideally, we'd like to tell the browser to pre-populate the
|
||||
// <input type="file"> with the selectedFile if we have one -- but
|
||||
// browser security prohibits that. So instead, we'll swap out the
|
||||
// new input (that has no file selected) with the old input (that
|
||||
// already has the selectedFile selected). However, we only want to do
|
||||
// this if the selected file is valid: if it isn't, we want to render
|
||||
// a blank input to prompt the user to upload a different (valid) file.
|
||||
if (selectedFile && isValid) {
|
||||
$(oldInput).removeClass("error");
|
||||
this.$('input[type=file]').replaceWith(oldInput);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
events: {
|
||||
"change input[type=file]": "selectFile",
|
||||
"click .action-cancel": "hideAndRemove",
|
||||
"click .action-upload": "upload"
|
||||
},
|
||||
selectFile: function(e) {
|
||||
this.model.set({
|
||||
selectedFile: e.target.files[0] || null
|
||||
});
|
||||
},
|
||||
show: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.options.shown = true;
|
||||
$body.addClass('dialog-is-shown');
|
||||
return this.render();
|
||||
},
|
||||
hide: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.options.shown = false;
|
||||
$body.removeClass('dialog-is-shown');
|
||||
return this.render();
|
||||
},
|
||||
hideAndRemove: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
return this.hide().remove();
|
||||
},
|
||||
upload: function(e) {
|
||||
if(e && e.preventDefault) { e.preventDefault(); }
|
||||
this.model.set('uploading', true);
|
||||
this.$("form").ajaxSubmit({
|
||||
success: _.bind(this.success, this),
|
||||
error: _.bind(this.error, this),
|
||||
uploadProgress: _.bind(this.progress, this),
|
||||
data: {
|
||||
// don't show the generic error notification; we're in a modal,
|
||||
// and we're better off modifying it instead.
|
||||
notifyOnError: false
|
||||
}
|
||||
});
|
||||
},
|
||||
progress: function(event, position, total, percentComplete) {
|
||||
this.model.set({
|
||||
"uploadedBytes": position,
|
||||
"totalBytes": total
|
||||
});
|
||||
},
|
||||
success: function(response, statusText, xhr, form) {
|
||||
this.model.set({
|
||||
uploading: false,
|
||||
finished: true
|
||||
});
|
||||
if(this.options.onSuccess) {
|
||||
this.options.onSuccess(response, statusText, xhr, form);
|
||||
}
|
||||
var that = this;
|
||||
this.removalTimeout = setTimeout(function() {
|
||||
that.hide().remove();
|
||||
}, this.options.successMessageTimeout);
|
||||
},
|
||||
error: function() {
|
||||
this.model.set({
|
||||
"uploading": false,
|
||||
"uploadedBytes": 0,
|
||||
"title": gettext("We're sorry, there was an error")
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -42,6 +42,7 @@
|
||||
@import 'elements/system-help'; // help UI
|
||||
@import 'elements/modal'; // interstitial UI, dialogs, modal windows
|
||||
@import 'elements/vendor'; // overrides to vendor-provided styling
|
||||
@import 'elements/uploads';
|
||||
|
||||
// base - specific views
|
||||
@import 'views/account';
|
||||
|
||||
@@ -225,8 +225,15 @@ form[class^="create-"] {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
.is-showing {
|
||||
@extend .anim-fadeIn;
|
||||
}
|
||||
|
||||
.is-hiding {
|
||||
@extend .anim-fadeOut;
|
||||
}
|
||||
|
||||
.tip-error {
|
||||
@extend .anim-fadeIn;
|
||||
display: block;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
209
cms/static/sass/elements/_uploads.scss
Normal file
209
cms/static/sass/elements/_uploads.scss
Normal file
@@ -0,0 +1,209 @@
|
||||
// studio - elements - uploads
|
||||
// ========================
|
||||
|
||||
body.course.feature-upload {
|
||||
|
||||
// dialog
|
||||
.wrapper-dialog {
|
||||
@extend .ui-depth5;
|
||||
@include transition(all 0.05s ease-in-out);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
background: $black-t2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
margin-right: -0.25em; /* Adjusts for spacing */
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@include box-sizing(border-box);
|
||||
box-shadow: 0px 0px 7px $shadow-d1;
|
||||
border-radius: ($baseline/5);
|
||||
background-color: $gray-l4;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: $baseline*23;
|
||||
padding: 7px;
|
||||
text-align: left;
|
||||
|
||||
.title {
|
||||
@extend .t-title5;
|
||||
margin-bottom: ($baseline/2);
|
||||
font-weight: 600;
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.message {
|
||||
@extend .t-copy-sub2;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 0;
|
||||
|
||||
.form-content {
|
||||
box-shadow: 0 0 3px $shadow-d1;
|
||||
padding: ($baseline*1.5);
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
@extend .t-copy-sub2;
|
||||
}
|
||||
|
||||
.status-upload {
|
||||
height: 30px;
|
||||
margin-top: $baseline;
|
||||
|
||||
.wrapper-progress {
|
||||
box-shadow: inset 0 0 3px $shadow-d1;
|
||||
display: block;
|
||||
border-radius: ($baseline*0.75);
|
||||
background-color: $gray-l5;
|
||||
padding: 1px 8px 2px 8px;
|
||||
height: 25px;
|
||||
|
||||
progress {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: ($baseline*0.75);
|
||||
background-color: $gray-l5;
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
background-color: transparent;
|
||||
border-radius: ($baseline*0.75);
|
||||
}
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: $pink;
|
||||
border-radius: ($baseline*0.75);
|
||||
}
|
||||
|
||||
&::-moz-progress-bar {
|
||||
background-color: $pink;
|
||||
border-radius: ($baseline*0.75);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.message-status {
|
||||
@include border-top-radius(2px);
|
||||
@include box-sizing(border-box);
|
||||
@include font-size(14);
|
||||
display: none;
|
||||
border-bottom: 2px solid $yellow;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
background: $paleYellow;
|
||||
|
||||
.text {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: $red-d2;
|
||||
background: $red-l1;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.confirm {
|
||||
border-color: $green-d2;
|
||||
background: $green-l1;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.is-shown {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: ($baseline*0.75) $baseline ($baseline/2) $baseline;
|
||||
|
||||
|
||||
|
||||
.action-item {
|
||||
@extend .t-action4;
|
||||
display: inline-block;
|
||||
margin-right: ($baseline*0.75);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@include blue-button();
|
||||
@include font-size(12); // needed due to bad button mixins for now
|
||||
border-color: $blue-d1;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $blue;
|
||||
|
||||
&:hover {
|
||||
color: $blue-s2;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// js enabled
|
||||
.js {
|
||||
|
||||
// dialog set-up
|
||||
.wrapper-dialog {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
.dialog {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// dialog showing/hiding
|
||||
&.dialog-is-shown {
|
||||
|
||||
.wrapper-dialog {
|
||||
-webkit-filter: blur(2px) grayscale(25%);
|
||||
filter: blur(2px) grayscale(25%);
|
||||
}
|
||||
|
||||
.wrapper-dialog.is-shown {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
|
||||
.dialog {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// ====================
|
||||
|
||||
// Video Alpha
|
||||
.xmodule_VideoAlphaModule {
|
||||
.xmodule_VideoModule {
|
||||
|
||||
// display mode
|
||||
&.xmodule_display {
|
||||
|
||||
@@ -131,7 +131,7 @@ body.course.settings {
|
||||
list-style: none;
|
||||
|
||||
.field {
|
||||
margin: 0 0 $baseline 0;
|
||||
margin: 0 0 ($baseline*2) 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -432,6 +432,61 @@ body.course.settings {
|
||||
}
|
||||
}
|
||||
|
||||
// specific fields - course image
|
||||
#field-course-image {
|
||||
|
||||
.current-course-image {
|
||||
margin-bottom: ($baseline/2);
|
||||
padding: ($baseline/2) $baseline;
|
||||
background: $gray-l5;
|
||||
text-align: center;
|
||||
|
||||
.wrapper-course-image {
|
||||
display: block;
|
||||
width: 375px;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
border: 1px solid $gray-l4;
|
||||
box-shadow: 0 1px 1px $shadow-l1;
|
||||
padding: ($baseline/2);
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.course-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.msg {
|
||||
@extend .t-copy-sub2;
|
||||
display: block;
|
||||
margin-top: ($baseline/2);
|
||||
color: $gray-l3;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-input {
|
||||
@include clearfix();
|
||||
width: flex-grid(9,9);
|
||||
|
||||
.input {
|
||||
float: left;
|
||||
width: flex-grid(6,9);
|
||||
margin-right: flex-gutter();
|
||||
}
|
||||
|
||||
.action-upload-image {
|
||||
@extend .ui-btn-flat-outline;
|
||||
float: right;
|
||||
width: flex-grid(2,9);
|
||||
margin-top: ($baseline/4);
|
||||
padding: ($baseline/2) $baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// specific fields - requirements
|
||||
&.requirements {
|
||||
|
||||
@@ -445,7 +500,7 @@ body.course.settings {
|
||||
margin-bottom: ($baseline*3);
|
||||
|
||||
.grade-controls {
|
||||
@include clearfix;
|
||||
@include clearfix();
|
||||
width: flex-grid(9,9);
|
||||
}
|
||||
|
||||
|
||||
@@ -370,213 +370,4 @@ body.course.textbooks {
|
||||
.content-supplementary {
|
||||
width: flex-grid(3, 12);
|
||||
}
|
||||
|
||||
// dialog
|
||||
.wrapper-dialog {
|
||||
@extend .ui-depth5;
|
||||
@include transition(all 0.05s ease-in-out);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
background: $black-t2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
margin-right: -0.25em; /* Adjusts for spacing */
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@include box-sizing(border-box);
|
||||
box-shadow: 0px 0px 7px $shadow-d1;
|
||||
border-radius: ($baseline/5);
|
||||
background-color: $gray-l4;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: $baseline*23;
|
||||
padding: 7px;
|
||||
text-align: left;
|
||||
|
||||
.title {
|
||||
@extend .t-title5;
|
||||
margin-bottom: ($baseline/2);
|
||||
font-weight: 600;
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.message {
|
||||
@extend .t-copy-sub2;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 0;
|
||||
|
||||
.form-content {
|
||||
box-shadow: 0 0 3px $shadow-d1;
|
||||
padding: ($baseline*1.5);
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
@extend .t-copy-sub2;
|
||||
}
|
||||
|
||||
.status-upload {
|
||||
height: 30px;
|
||||
margin-top: $baseline;
|
||||
|
||||
.wrapper-progress {
|
||||
box-shadow: inset 0 0 3px $shadow-d1;
|
||||
display: block;
|
||||
border-radius: ($baseline*0.75);
|
||||
background-color: $gray-l5;
|
||||
padding: 1px 8px 2px 8px;
|
||||
height: 25px;
|
||||
|
||||
progress {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: ($baseline*0.75);
|
||||
background-color: $gray-l5;
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
background-color: transparent;
|
||||
border-radius: ($baseline*0.75);
|
||||
}
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: $pink;
|
||||
border-radius: ($baseline*0.75);
|
||||
}
|
||||
|
||||
&::-moz-progress-bar {
|
||||
background-color: $pink;
|
||||
border-radius: ($baseline*0.75);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.message-status {
|
||||
@include border-top-radius(2px);
|
||||
@include box-sizing(border-box);
|
||||
@include font-size(14);
|
||||
display: none;
|
||||
border-bottom: 2px solid $yellow;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
background: $paleYellow;
|
||||
|
||||
.text {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: $red-d2;
|
||||
background: $red-l1;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.confirm {
|
||||
border-color: $green-d2;
|
||||
background: $green-l1;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.is-shown {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: ($baseline*0.75) $baseline ($baseline/2) $baseline;
|
||||
|
||||
|
||||
|
||||
.action-item {
|
||||
@extend .t-action4;
|
||||
display: inline-block;
|
||||
margin-right: ($baseline*0.75);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@include blue-button();
|
||||
@include font-size(12); // needed due to bad button mixins for now
|
||||
border-color: $blue-d1;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $blue;
|
||||
|
||||
&:hover {
|
||||
color: $blue-s2;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// js enabled
|
||||
.js {
|
||||
|
||||
// dialog set-up
|
||||
.wrapper-dialog {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
.dialog {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// dialog showing/hiding
|
||||
&.dialog-is-shown {
|
||||
|
||||
.wrapper-dialog {
|
||||
-webkit-filter: blur(2px) grayscale(25%);
|
||||
filter: blur(2px) grayscale(25%);
|
||||
}
|
||||
|
||||
.wrapper-dialog.is-shown {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
|
||||
.dialog {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -99,23 +99,27 @@
|
||||
<label for="new-course-name">${_("Course Name")}</label>
|
||||
<input class="new-course-name" id="new-course-name" type="text" name="new-course-name" aria-required="true" placeholder="${_('e.g. Introduction to Computer Science')}" />
|
||||
<span class="tip tip-stacked">${_("The public display name for your course.")}</span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
</li>
|
||||
<li class="field field-inline text required" id="field-organization">
|
||||
<label for="new-course-org">${_("Organization")}</label>
|
||||
<input class="new-course-org" id="new-course-org" type="text" name="new-course-org" aria-required="true" placeholder="${_('e.g. MITX or IMF')}" />
|
||||
<span class="tip tip-stacked">${_("The name of the organization sponsoring the course")} - <strong>${_("Note: No spaces or special characters are allowed. This cannot be changed.")}</strong></span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
</li>
|
||||
|
||||
<li class="field field-inline text required" id="field-course-number">
|
||||
<label for="new-course-number">${_("Course Number")}</label>
|
||||
<input class="new-course-number" id="new-course-number" type="text" name="new-course-number" aria-required="true" placeholder="${_('e.g. CS101')}" />
|
||||
<span class="tip tip-stacked">${_("The unique number that identifies your course within your organization")} - <strong>${_("Note: No spaces or special characters are allowed. This cannot be changed.")}</strong></span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
</li>
|
||||
|
||||
<li class="field field-inline text required" id="field-course-run">
|
||||
<label for="new-course-run">${_("Course Run")}</label>
|
||||
<input class="new-course-run" id="new-course-run" type="text" name="new-course-run" aria-required="true"placeholder="${_('e.g. 2013_Spring')}" />
|
||||
<span class="tip tip-stacked">${_("The term in which your course will run")} - <strong>${_("Note: No spaces or special characters are allowed. This cannot be changed.")}</strong></span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -123,7 +127,7 @@
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<input type="submit" value="${_('Save')}" class="action action-primary new-course-save" />
|
||||
<input type="submit" value="${_('Create')}" class="action action-primary new-course-save" />
|
||||
<input type="button" value="${_('Cancel')}" class="action action-secondary action-cancel new-course-cancel" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<h2 class="title"><%= title %></h2>
|
||||
<% if(error) {%>
|
||||
<div id="upload_error" class="message message-status message-status error is-shown" name="upload_error">
|
||||
<p><%= gettext(error.message) %></p>
|
||||
<p><%= error.message %></p>
|
||||
</div>
|
||||
<% } %>
|
||||
<p id="dialog-assetupload-description" class="message"><%= message %></p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<%inherit file="base.html" />
|
||||
<%block name="title">${_("Schedule & Details Settings")}</%block>
|
||||
<%block name="bodyclass">is-signedin course schedule settings</%block>
|
||||
<%block name="bodyclass">is-signedin course schedule settings feature-upload</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
@@ -22,6 +22,10 @@ from contentstore import utils
|
||||
<script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/models/settings/course_details.js')}"></script>
|
||||
|
||||
<script type="text/template" id="upload-dialog-tpl">
|
||||
<%static:include path="js/upload-dialog.underscore" />
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function(){
|
||||
|
||||
@@ -43,6 +47,8 @@ from contentstore import utils
|
||||
},
|
||||
reset: true
|
||||
});
|
||||
|
||||
CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -208,6 +214,34 @@ from contentstore import utils
|
||||
<span class="tip tip-stacked">${overview_text()}</span>
|
||||
</li>
|
||||
|
||||
<li class="field image" id="field-course-image">
|
||||
<label>${_("Course Image")}</label>
|
||||
<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')}"/>
|
||||
</span>
|
||||
|
||||
<% ctx_loc = context_course.location %>
|
||||
<span class="msg msg-help">${_("You can manage this image along with all of your other")} <a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("files & uploads")}</a></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')}"/>
|
||||
</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
|
||||
</div>
|
||||
|
||||
<div class="wrapper-input">
|
||||
<div class="input">
|
||||
<input type="text" class="long new-course-image-url" id="course-image-url" value="" placeholder="Your course image URL" autocomplete="off" />
|
||||
<span class="tip tip-stacked">${_("Please provide a valid path and name to your course image (Note: only JPEG or PNG format supported)")}</span>
|
||||
</div>
|
||||
<button type="button" class="action action-upload-image">${_("Upload Course Image")}</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="field video" id="field-course-introduction-video">
|
||||
<label for="course-overview">${_("Course Introduction Video")}</label>
|
||||
<div class="input input-existing">
|
||||
|
||||
@@ -37,7 +37,7 @@ from contentstore import utils
|
||||
<section class="settings-faculty-members">
|
||||
<header>
|
||||
<h3>${_("Faculty Members")}</h3>
|
||||
<span class="detail">${_("Individuals instructing and help with this course")}</span>
|
||||
<span class="detail">${_("Individuals instructing and helping with this course")}</span>
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
|
||||
@@ -97,7 +97,7 @@ from contentstore import utils
|
||||
<ol class="list-input">
|
||||
<li class="field text" id="field-course-grading-graceperiod">
|
||||
<label for="course-grading-graceperiod">${_("Grace Period on Deadline:")}</label>
|
||||
<input type="text" class="short time" id="course-grading-graceperiod" value="0:00" placeholder="HH:MM" autocomplete="off" />
|
||||
<input type="text" class="short time" id="course-grading-graceperiod" value="00:00" placeholder="HH:MM" autocomplete="off" />
|
||||
<span class="tip tip-inline">${_("Leeway on due dates")}</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<%block name="title">${_("Textbooks")}</%block>
|
||||
<%block name="bodyclass">is-signedin course textbooks</%block>
|
||||
<%block name="bodyclass">is-signedin course textbooks feature-upload</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
% for template_name in ["edit-textbook", "show-textbook", "edit-chapter", "no-textbooks", "upload-dialog"]:
|
||||
|
||||
4
common/djangoapps/course_modes/admin.py
Normal file
4
common/djangoapps/course_modes/admin.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from ratelimitbackend import admin
|
||||
from course_modes.models import CourseMode
|
||||
|
||||
admin.site.register(CourseMode)
|
||||
40
common/djangoapps/course_modes/migrations/0001_initial.py
Normal file
40
common/djangoapps/course_modes/migrations/0001_initial.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# -*- 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):
|
||||
# Adding model 'CourseMode'
|
||||
db.create_table('course_modes_coursemode', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('mode_slug', self.gf('django.db.models.fields.CharField')(max_length=100)),
|
||||
('mode_display_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
|
||||
('min_price', self.gf('django.db.models.fields.IntegerField')(default=0)),
|
||||
('suggested_prices', self.gf('django.db.models.fields.CommaSeparatedIntegerField')(default='', max_length=255, blank=True)),
|
||||
))
|
||||
db.send_create_signal('course_modes', ['CourseMode'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'CourseMode'
|
||||
db.delete_table('course_modes_coursemode')
|
||||
|
||||
|
||||
models = {
|
||||
'course_modes.coursemode': {
|
||||
'Meta': {'object_name': 'CourseMode'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['course_modes']
|
||||
@@ -0,0 +1,35 @@
|
||||
# -*- 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):
|
||||
# Adding field 'CourseMode.currency'
|
||||
db.add_column('course_modes_coursemode', 'currency',
|
||||
self.gf('django.db.models.fields.CharField')(default='usd', max_length=8),
|
||||
keep_default=False)
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting field 'CourseMode.currency'
|
||||
db.delete_column('course_modes_coursemode', 'currency')
|
||||
|
||||
|
||||
models = {
|
||||
'course_modes.coursemode': {
|
||||
'Meta': {'object_name': 'CourseMode'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['course_modes']
|
||||
@@ -0,0 +1,33 @@
|
||||
# -*- 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):
|
||||
# Adding unique constraint on 'CourseMode', fields ['course_id', 'currency', 'mode_slug']
|
||||
db.create_unique('course_modes_coursemode', ['course_id', 'currency', 'mode_slug'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Removing unique constraint on 'CourseMode', fields ['course_id', 'currency', 'mode_slug']
|
||||
db.delete_unique('course_modes_coursemode', ['course_id', 'currency', 'mode_slug'])
|
||||
|
||||
|
||||
models = {
|
||||
'course_modes.coursemode': {
|
||||
'Meta': {'unique_together': "(('course_id', 'mode_slug', 'currency'),)", 'object_name': 'CourseMode'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['course_modes']
|
||||
53
common/djangoapps/course_modes/models.py
Normal file
53
common/djangoapps/course_modes/models.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Add and create new modes for running courses on this particular LMS
|
||||
"""
|
||||
from django.db import models
|
||||
from collections import namedtuple
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
Mode = namedtuple('Mode', ['slug', 'name', 'min_price', 'suggested_prices', 'currency'])
|
||||
|
||||
|
||||
class CourseMode(models.Model):
|
||||
"""
|
||||
We would like to offer a course in a variety of modes.
|
||||
|
||||
"""
|
||||
# the course that this mode is attached to
|
||||
course_id = models.CharField(max_length=255, db_index=True)
|
||||
|
||||
# the reference to this mode that can be used by Enrollments to generate
|
||||
# similar behavior for the same slug across courses
|
||||
mode_slug = models.CharField(max_length=100)
|
||||
|
||||
# The 'pretty' name that can be translated and displayed
|
||||
mode_display_name = models.CharField(max_length=255)
|
||||
|
||||
# minimum price in USD that we would like to charge for this mode of the course
|
||||
min_price = models.IntegerField(default=0)
|
||||
|
||||
# the suggested prices for this mode
|
||||
suggested_prices = models.CommaSeparatedIntegerField(max_length=255, blank=True, default='')
|
||||
|
||||
# the currency these prices are in, using lower case ISO currency codes
|
||||
currency = models.CharField(default="usd", max_length=8)
|
||||
|
||||
DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd')
|
||||
|
||||
class Meta:
|
||||
""" meta attributes of this model """
|
||||
unique_together = ('course_id', 'mode_slug', 'currency')
|
||||
|
||||
@classmethod
|
||||
def modes_for_course(cls, course_id):
|
||||
"""
|
||||
Returns a list of the modes for a given course id
|
||||
|
||||
If no modes have been set in the table, returns the default mode
|
||||
"""
|
||||
found_course_modes = cls.objects.filter(course_id=course_id)
|
||||
modes = ([Mode(mode.mode_slug, mode.mode_display_name, mode.min_price, mode.suggested_prices, mode.currency)
|
||||
for mode in found_course_modes])
|
||||
if not modes:
|
||||
modes = [cls.DEFAULT_MODE]
|
||||
return modes
|
||||
62
common/djangoapps/course_modes/tests.py
Normal file
62
common/djangoapps/course_modes/tests.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
This file demonstrates writing tests using the unittest module. These will pass
|
||||
when you run "manage.py test".
|
||||
|
||||
Replace this with more appropriate tests for your application.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from course_modes.models import CourseMode, Mode
|
||||
|
||||
|
||||
class CourseModeModelTest(TestCase):
|
||||
"""
|
||||
Tests for the CourseMode model
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.course_id = 'TestCourse'
|
||||
CourseMode.objects.all().delete()
|
||||
|
||||
def create_mode(self, mode_slug, mode_name, min_price=0, suggested_prices='', currency='usd'):
|
||||
"""
|
||||
Create a new course mode
|
||||
"""
|
||||
CourseMode.objects.get_or_create(
|
||||
course_id=self.course_id,
|
||||
mode_display_name=mode_name,
|
||||
mode_slug=mode_slug,
|
||||
min_price=min_price,
|
||||
suggested_prices=suggested_prices,
|
||||
currency=currency
|
||||
)
|
||||
|
||||
def test_modes_for_course_empty(self):
|
||||
"""
|
||||
If we can't find any modes, we should get back the default mode
|
||||
"""
|
||||
# shouldn't be able to find a corresponding course
|
||||
modes = CourseMode.modes_for_course(self.course_id)
|
||||
self.assertEqual([CourseMode.DEFAULT_MODE], modes)
|
||||
|
||||
def test_nodes_for_course_single(self):
|
||||
"""
|
||||
Find the modes for a course with only one mode
|
||||
"""
|
||||
|
||||
self.create_mode('verified', 'Verified Certificate')
|
||||
modes = CourseMode.modes_for_course(self.course_id)
|
||||
self.assertEqual([Mode(u'verified', u'Verified Certificate', 0, '', 'usd')], modes)
|
||||
|
||||
def test_modes_for_course_multiple(self):
|
||||
"""
|
||||
Finding the modes when there's multiple modes
|
||||
"""
|
||||
mode1 = Mode(u'honor', u'Honor Code Certificate', 0, '', 'usd')
|
||||
mode2 = Mode(u'verified', u'Verified Certificate', 0, '', 'usd')
|
||||
set_modes = [mode1, mode2]
|
||||
for mode in set_modes:
|
||||
self.create_mode(mode.slug, mode.name, mode.min_price, mode.suggested_prices)
|
||||
|
||||
modes = CourseMode.modes_for_course(self.course_id)
|
||||
self.assertEqual(modes, set_modes)
|
||||
1
common/djangoapps/course_modes/views.py
Normal file
1
common/djangoapps/course_modes/views.py
Normal file
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
@@ -19,6 +19,20 @@ FORUM_ROLE_STUDENT = 'Student'
|
||||
|
||||
@receiver(post_save, sender=CourseEnrollment)
|
||||
def assign_default_role(sender, instance, **kwargs):
|
||||
# The code below would remove all forum Roles from a user when they unenroll
|
||||
# from a course. Concerns were raised that it should apply only to students,
|
||||
# or that even the history of student roles is important for research
|
||||
# purposes. Since this was new functionality being added in this release,
|
||||
# I'm just going to comment it out for now and let the forums team deal with
|
||||
# implementing the right behavior.
|
||||
#
|
||||
# # We've unenrolled the student, so remove all roles for this course
|
||||
# if not instance.is_active:
|
||||
# course_roles = list(Role.objects.filter(course_id=instance.course_id))
|
||||
# instance.user.roles.remove(*course_roles)
|
||||
# return
|
||||
|
||||
# We've enrolled the student, so make sure they have a default role
|
||||
if instance.user.is_staff:
|
||||
role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0]
|
||||
else:
|
||||
|
||||
58
common/djangoapps/django_comment_common/tests.py
Normal file
58
common/djangoapps/django_comment_common/tests.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from django_comment_common.models import Role
|
||||
from student.models import CourseEnrollment, User
|
||||
|
||||
class RoleAssignmentTest(TestCase):
|
||||
"""
|
||||
Basic checks to make sure our Roles get assigned and unassigned as students
|
||||
are enrolled and unenrolled from a course.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.staff_user = User.objects.create_user(
|
||||
"patty",
|
||||
"patty@fake.edx.org",
|
||||
)
|
||||
self.staff_user.is_staff = True
|
||||
|
||||
self.student_user = User.objects.create_user(
|
||||
"hacky",
|
||||
"hacky@fake.edx.org"
|
||||
)
|
||||
self.course_id = "edX/Fake101/2012"
|
||||
CourseEnrollment.enroll(self.staff_user, self.course_id)
|
||||
CourseEnrollment.enroll(self.student_user, self.course_id)
|
||||
|
||||
def test_enrollment_auto_role_creation(self):
|
||||
moderator_role = Role.objects.get(
|
||||
course_id=self.course_id,
|
||||
name="Moderator"
|
||||
)
|
||||
student_role = Role.objects.get(
|
||||
course_id=self.course_id,
|
||||
name="Student"
|
||||
)
|
||||
self.assertIn(moderator_role, self.staff_user.roles.all())
|
||||
|
||||
self.assertIn(student_role, self.student_user.roles.all())
|
||||
self.assertNotIn(moderator_role, self.student_user.roles.all())
|
||||
|
||||
# The following was written on the assumption that unenrolling from a course
|
||||
# should remove all forum Roles for that student for that course. This is
|
||||
# not necessarily the case -- please see comments at the top of
|
||||
# django_comment_client.models.assign_default_role(). Leaving it for the
|
||||
# forums team to sort out.
|
||||
#
|
||||
# def test_unenrollment_auto_role_removal(self):
|
||||
# another_student = User.objects.create_user("sol", "sol@fake.edx.org")
|
||||
# CourseEnrollment.enroll(another_student, self.course_id)
|
||||
#
|
||||
# CourseEnrollment.unenroll(self.student_user, self.course_id)
|
||||
# # Make sure we didn't delete the actual Role
|
||||
# student_role = Role.objects.get(
|
||||
# course_id=self.course_id,
|
||||
# name="Student"
|
||||
# )
|
||||
# self.assertNotIn(student_role, self.student_user.roles.all())
|
||||
# self.assertIn(student_role, another_student.roles.all())
|
||||
@@ -431,12 +431,12 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
# If course is not limited or student has correct shib extauth then enrollment should be allowed
|
||||
if course is open_enroll_course or student is shib_student:
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1)
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(student, course.id))
|
||||
# Clean up
|
||||
CourseEnrollment.objects.filter(user=student, course_id=course.id).delete()
|
||||
CourseEnrollment.unenroll(student, course.id)
|
||||
else:
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0)
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_shib_login_enrollment(self):
|
||||
@@ -462,7 +462,7 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
|
||||
# use django test client for sessions and url processing
|
||||
# no enrollment before trying
|
||||
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0)
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
|
||||
self.client.logout()
|
||||
request_kwargs = {'path': '/shib-login/',
|
||||
'data': {'enrollment_action': 'enroll', 'course_id': course.id},
|
||||
@@ -474,4 +474,4 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['location'], 'http://testserver/')
|
||||
# now there is enrollment
|
||||
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1)
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(student, course.id))
|
||||
|
||||
@@ -6,7 +6,7 @@ from staticfiles import finders
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.modulestore import XML_MODULESTORE_TYPE
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -90,7 +90,7 @@ def replace_course_urls(text, course_id):
|
||||
return re.sub(_url_replace_regex('/course/'), replace_course_url, text)
|
||||
|
||||
|
||||
def replace_static_urls(text, data_directory, course_namespace=None):
|
||||
def replace_static_urls(text, data_directory, course_id=None, static_asset_path=''):
|
||||
"""
|
||||
Replace /static/$stuff urls either with their correct url as generated by collectstatic,
|
||||
(/static/$md5_hashed_stuff) or by the course-specific content static url
|
||||
@@ -99,7 +99,8 @@ def replace_static_urls(text, data_directory, course_namespace=None):
|
||||
|
||||
text: The source text to do the substitution in
|
||||
data_directory: The directory in which course data is stored
|
||||
course_namespace: The course identifier used to distinguish static content for this course in studio
|
||||
course_id: The course identifier used to distinguish static content for this course in studio
|
||||
static_asset_path: Path for static assets, which overrides data_directory and course_namespace, if nonempty
|
||||
"""
|
||||
|
||||
def replace_static_url(match):
|
||||
@@ -116,18 +117,26 @@ def replace_static_urls(text, data_directory, course_namespace=None):
|
||||
if settings.DEBUG and finders.find(rest, True):
|
||||
return original
|
||||
# if we're running with a MongoBacked store course_namespace is not None, then use studio style urls
|
||||
elif course_namespace is not None and not isinstance(modulestore(), XMLModuleStore):
|
||||
elif (not static_asset_path) and course_id and modulestore().get_modulestore_type(course_id) != XML_MODULESTORE_TYPE:
|
||||
# first look in the static file pipeline and see if we are trying to reference
|
||||
# a piece of static content which is in the mitx repo (e.g. JS associated with an xmodule)
|
||||
if staticfiles_storage.exists(rest):
|
||||
|
||||
exists_in_staticfiles_storage = False
|
||||
try:
|
||||
exists_in_staticfiles_storage = staticfiles_storage.exists(rest)
|
||||
except Exception as err:
|
||||
log.warning("staticfiles_storage couldn't find path {0}: {1}".format(
|
||||
rest, str(err)))
|
||||
|
||||
if exists_in_staticfiles_storage:
|
||||
url = staticfiles_storage.url(rest)
|
||||
else:
|
||||
# if not, then assume it's courseware specific content and then look in the
|
||||
# Mongo-backed database
|
||||
url = StaticContent.convert_legacy_static_url(rest, course_namespace)
|
||||
url = StaticContent.convert_legacy_static_url_with_course_id(rest, course_id)
|
||||
# Otherwise, look the file up in staticfiles_storage, and append the data directory if needed
|
||||
else:
|
||||
course_path = "/".join((data_directory, rest))
|
||||
course_path = "/".join((static_asset_path or data_directory, rest))
|
||||
|
||||
try:
|
||||
if staticfiles_storage.exists(rest):
|
||||
@@ -142,8 +151,9 @@ def replace_static_urls(text, data_directory, course_namespace=None):
|
||||
|
||||
return "".join([quote, url, quote])
|
||||
|
||||
|
||||
return re.sub(
|
||||
_url_replace_regex('/static/(?!{data_dir})'.format(data_dir=data_directory)),
|
||||
_url_replace_regex('/static/(?!{data_dir})'.format(data_dir=static_asset_path or data_directory)),
|
||||
replace_static_url,
|
||||
text
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ from xmodule.modulestore.xml import XMLModuleStore
|
||||
|
||||
DATA_DIRECTORY = 'data_dir'
|
||||
COURSE_ID = 'org/course/run'
|
||||
NAMESPACE = Location('org', 'course', 'run', None, None)
|
||||
STATIC_SOURCE = '"/static/file.png"'
|
||||
|
||||
|
||||
@@ -52,18 +51,18 @@ def test_storage_url_not_exists(mock_storage):
|
||||
def test_mongo_filestore(mock_modulestore, mock_static_content):
|
||||
|
||||
mock_modulestore.return_value = Mock(MongoModuleStore)
|
||||
mock_static_content.convert_legacy_static_url.return_value = "c4x://mock_url"
|
||||
mock_static_content.convert_legacy_static_url_with_course_id.return_value = "c4x://mock_url"
|
||||
|
||||
# No namespace => no change to path
|
||||
assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY))
|
||||
|
||||
# Namespace => content url
|
||||
assert_equals(
|
||||
'"' + mock_static_content.convert_legacy_static_url.return_value + '"',
|
||||
replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY, NAMESPACE)
|
||||
'"' + mock_static_content.convert_legacy_static_url_with_course_id.return_value + '"',
|
||||
replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY, course_id=COURSE_ID)
|
||||
)
|
||||
|
||||
mock_static_content.convert_legacy_static_url.assert_called_once_with('file.png', NAMESPACE)
|
||||
mock_static_content.convert_legacy_static_url_with_course_id.assert_called_once_with('file.png', COURSE_ID)
|
||||
|
||||
|
||||
@patch('static_replace.settings')
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Dump username,unique_id_for_user pairs as CSV.
|
||||
|
||||
Give instructors easy access to the mapping from anonymized IDs to user IDs
|
||||
with a simple Django management command to generate a CSV mapping. To run, use
|
||||
the following:
|
||||
|
||||
rake django-admin[anonymized_id_mapping,x,y,z]
|
||||
|
||||
[Naturally, substitute the appropriate values for x, y, and z. (I.e.,
|
||||
lms, dev, and MITx/6.002x/Circuits)]"""
|
||||
|
||||
import csv
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import unique_id_for_user
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Add our handler to the space where django-admin looks up commands."""
|
||||
|
||||
# It appears that with the way Rake invokes these commands, we can't
|
||||
# have more than one arg passed through...annoying.
|
||||
args = ("course_id", )
|
||||
|
||||
help = """Export a CSV mapping usernames to anonymized ids
|
||||
|
||||
Exports a CSV document mapping each username in the specified course to
|
||||
the anonymized, unique user ID.
|
||||
"""
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1:
|
||||
raise CommandError("Usage: unique_id_mapping %s" %
|
||||
" ".join(("<%s>" % arg for arg in Command.args)))
|
||||
|
||||
course_id = args[0]
|
||||
|
||||
# Generate the output filename from the course ID.
|
||||
# Change slashes to dashes first, and then append .csv extension.
|
||||
output_filename = course_id.replace('/', '-') + ".csv"
|
||||
|
||||
# Figure out which students are enrolled in the course
|
||||
students = User.objects.filter(courseenrollment__course_id=course_id)
|
||||
if len(students) == 0:
|
||||
self.stdout.write("No students enrolled in %s" % course_id)
|
||||
return
|
||||
|
||||
# Write mapping to output file in CSV format with a simple header
|
||||
try:
|
||||
with open(output_filename, 'wb') as output_file:
|
||||
csv_writer = csv.writer(output_file)
|
||||
csv_writer.writerow(("User ID", "Anonymized user ID"))
|
||||
for student in students:
|
||||
csv_writer.writerow((student.id, unique_id_for_user(student)))
|
||||
except IOError:
|
||||
raise CommandError("Error writing to file: %s" % output_filename)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user