diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 897ea3ae3a..749b9ef56e 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -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 ```` 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.
diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py
index e04c108250..69050539cf 100644
--- a/cms/djangoapps/auth/tests/test_authz.py
+++ b/cms/djangoapps/auth/tests/test_authz.py
@@ -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)
diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature
index a11a6cb869..b2941ac7a5 100644
--- a/cms/djangoapps/contentstore/features/advanced-settings.feature
+++ b/cms/djangoapps/contentstore/features/advanced-settings.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/checklists.feature b/cms/djangoapps/contentstore/features/checklists.feature
index f13ce53fc2..6289df9cfc 100644
--- a/cms/djangoapps/contentstore/features/checklists.feature
+++ b/cms/djangoapps/contentstore/features/checklists.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py
index 8d13a39bb3..5d6fde47c8 100644
--- a/cms/djangoapps/contentstore/features/common.py
+++ b/cms/djangoapps/contentstore/features/common.py
@@ -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)
diff --git a/cms/djangoapps/contentstore/features/course-overview.feature b/cms/djangoapps/contentstore/features/course-overview.feature
index a9aed5d982..2cbb22ddd7 100644
--- a/cms/djangoapps/contentstore/features/course-overview.feature
+++ b/cms/djangoapps/contentstore/features/course-overview.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature
index 5c79dc7ee3..9976179b68 100644
--- a/cms/djangoapps/contentstore/features/course-settings.feature
+++ b/cms/djangoapps/contentstore/features/course-settings.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py
index da72d893cf..570c49a8c4 100644
--- a/cms/djangoapps/contentstore/features/course-settings.py
+++ b/cms/djangoapps/contentstore/features/course-settings.py
@@ -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, "
Overview
")
+@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):
diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py
index db7b4d81f9..8b31d325e5 100644
--- a/cms/djangoapps/contentstore/features/course-team.py
+++ b/cms/djangoapps/contentstore/features/course-team.py
@@ -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'
diff --git a/cms/djangoapps/contentstore/features/course-updates.feature b/cms/djangoapps/contentstore/features/course-updates.feature
index fb18e51f2d..41ee785db5 100644
--- a/cms/djangoapps/contentstore/features/course-updates.feature
+++ b/cms/djangoapps/contentstore/features/course-updates.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/courses.feature b/cms/djangoapps/contentstore/features/courses.feature
index 455313b0e2..a0ba8099ac 100644
--- a/cms/djangoapps/contentstore/features/courses.feature
+++ b/cms/djangoapps/contentstore/features/courses.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/discussion-editor.feature b/cms/djangoapps/contentstore/features/discussion-editor.feature
index 8fb14c3205..e4b1f5450b 100644
--- a/cms/djangoapps/contentstore/features/discussion-editor.feature
+++ b/cms/djangoapps/contentstore/features/discussion-editor.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature
index b01d762d73..0b34feb7aa 100644
--- a/cms/djangoapps/contentstore/features/grading.feature
+++ b/cms/djangoapps/contentstore/features/grading.feature
@@ -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"
diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py
index 0b60510bf5..93e44b3893 100644
--- a/cms/djangoapps/contentstore/features/grading.py
+++ b/cms/djangoapps/contentstore/features/grading.py
@@ -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):
diff --git a/cms/djangoapps/contentstore/features/html-editor.feature b/cms/djangoapps/contentstore/features/html-editor.feature
index 4cd5e1c1b9..4419d6018b 100644
--- a/cms/djangoapps/contentstore/features/html-editor.feature
+++ b/cms/djangoapps/contentstore/features/html-editor.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature
index 50c49a1896..1296acec1c 100644
--- a/cms/djangoapps/contentstore/features/problem-editor.feature
+++ b/cms/djangoapps/contentstore/features/problem-editor.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/static-pages.feature b/cms/djangoapps/contentstore/features/static-pages.feature
index 9997df69f0..c1a8ec91fc 100644
--- a/cms/djangoapps/contentstore/features/static-pages.feature
+++ b/cms/djangoapps/contentstore/features/static-pages.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature
index 84755b3644..6703c60c3b 100644
--- a/cms/djangoapps/contentstore/features/subsection.feature
+++ b/cms/djangoapps/contentstore/features/subsection.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/textbooks.feature b/cms/djangoapps/contentstore/features/textbooks.feature
index 0758a0b57b..36de10daa1 100644
--- a/cms/djangoapps/contentstore/features/textbooks.feature
+++ b/cms/djangoapps/contentstore/features/textbooks.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/textbooks.py b/cms/djangoapps/contentstore/features/textbooks.py
index d9c08ec6eb..b432b84d4f 100644
--- a/cms/djangoapps/contentstore/features/textbooks.py
+++ b/cms/djangoapps/contentstore/features/textbooks.py
@@ -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')
diff --git a/cms/djangoapps/contentstore/features/upload.feature b/cms/djangoapps/contentstore/features/upload.feature
index 8d40163685..441de597ea 100644
--- a/cms/djangoapps/contentstore/features/upload.feature
+++ b/cms/djangoapps/contentstore/features/upload.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py
index a989d6c07f..882b36e6b2 100644
--- a/cms/djangoapps/contentstore/features/upload.py
+++ b/cms/djangoapps/contentstore/features/upload.py
@@ -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)
diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature
index f28ee568dc..d238a7e523 100644
--- a/cms/djangoapps/contentstore/features/video-editor.feature
+++ b/cms/djangoapps/contentstore/features/video-editor.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py
index ad3229ab53..312e2d545f 100644
--- a/cms/djangoapps/contentstore/features/video-editor.py
+++ b/cms/djangoapps/contentstore/features/video-editor.py
@@ -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)
+
diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature
index 634bb8a17f..d2f9915f55 100644
--- a/cms/djangoapps/contentstore/features/video.feature
+++ b/cms/djangoapps/contentstore/features/video.feature
@@ -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
diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py
index e27ca28eb7..afa9953c90 100644
--- a/cms/djangoapps/contentstore/features/video.py
+++ b/cms/djangoapps/contentstore/features/video.py
@@ -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']
+
diff --git a/cms/djangoapps/contentstore/management/commands/check_course.py b/cms/djangoapps/contentstore/management/commands/check_course.py
index 215bb8add8..2f0b0b2a2c 100644
--- a/cms/djangoapps/contentstore/management/commands/check_course.py
+++ b/cms/djangoapps/contentstore/management/commands/check_course.py
@@ -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)
diff --git a/cms/djangoapps/contentstore/management/commands/clone_course.py b/cms/djangoapps/contentstore/management/commands/clone_course.py
index 5fffe29543..aa0e076f08 100644
--- a/cms/djangoapps/contentstore/management/commands/clone_course.py
+++ b/cms/djangoapps/contentstore/management/commands/clone_course.py
@@ -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))
diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py
index 4d8c4eda55..b0901ccfc9 100644
--- a/cms/djangoapps/contentstore/management/commands/delete_course.py
+++ b/cms/djangoapps/contentstore/management/commands/delete_course.py
@@ -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))
diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py
index 46f439b055..e0d58b32f0 100644
--- a/cms/djangoapps/contentstore/management/commands/import.py
+++ b/cms/djangoapps/contentstore/management/commands/import.py
@@ -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: [...]")
+ raise CommandError("import requires at least one argument: [--nostatic] [...]")
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)
diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py
index bce4b0326c..c0e1ff7207 100644
--- a/cms/djangoapps/contentstore/module_info_model.py
+++ b/cms/djangoapps/contentstore/module_info_model.py
@@ -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 {
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index 23135964a9..f03ee3b81a 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -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('
', html_module.data)
+
+ # get the sample HTML with just a simple tag information
+ html_module = module_store.get_instance(
+ 'edX/toy/2012_Fall',
+ Location(['i4x', 'edX', 'toy', 'html', 'just_img'])
+ )
+ self.assertIn('', 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 = '''
'''
- 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 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
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 2007ba2f69..dbdf8b3f6e 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -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:
diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py
index e12711a6ff..e543b7b517 100644
--- a/cms/djangoapps/contentstore/tests/test_crud.py
+++ b/cms/djangoapps/contentstore/tests/test_crud.py
@@ -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 = 'boo'
- 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 = 'boo'
- 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="")
+ first_problem = persistent_factories.ItemFactory.create(
+ display_name='problem 1', parent_location=sub.location, user_id='testbot', category='problem',
+ data=""
+ )
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="")
+ user_id='testbot', category='problem',
+ data=""
+ )
# 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
+
diff --git a/cms/djangoapps/contentstore/tests/test_import_nostatic.py b/cms/djangoapps/contentstore/tests/test_import_nostatic.py
new file mode 100644
index 0000000000..aad6ffbfe4
--- /dev/null
+++ b/cms/djangoapps/contentstore/tests/test_import_nostatic.py
@@ -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)
diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py
index 827dd1b054..260444a8f7 100644
--- a/cms/djangoapps/contentstore/tests/test_item.py
+++ b/cms/djangoapps/contentstore/tests/test_item.py
@@ -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):
diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py
index a9216da612..cbb8aa8b01 100644
--- a/cms/djangoapps/contentstore/tests/test_users.py
+++ b/cms/djangoapps/contentstore/tests/test_users.py
@@ -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'
)
diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py
index 26c49843b5..3d6d1d0c56 100644
--- a/cms/djangoapps/contentstore/tests/test_utils.py
+++ b/cms/djangoapps/contentstore/tests/test_utils.py
@@ -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))
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index a2e927ef46..e5ae6bb66b 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -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):
diff --git a/cms/djangoapps/contentstore/views/access.py b/cms/djangoapps/contentstore/views/access.py
index 49ce0c8733..5cb6b8c6f4 100644
--- a/cms/djangoapps/contentstore/views/access.py
+++ b/cms/djangoapps/contentstore/views/access.py
@@ -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
diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py
index 2334c61b4c..74cb94a354 100644
--- a/cms/djangoapps/contentstore/views/assets.py
+++ b/cms/djangoapps/contentstore/views/assets.py
@@ -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': '',
diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py
index 74f0a33769..030aa70693 100644
--- a/cms/djangoapps/contentstore/views/checklist.py
+++ b/cms/djangoapps/contentstore/views/checklist.py
@@ -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()
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 7cb503db1e..a5fec7c033 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -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)
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 8ac1d223cb..aad56e4a2e 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -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()
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index efebded9b9..bbd95dba84 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -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()
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index f2a07abe32..7a3a224d86 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -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
diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py
index a5c495597c..9414bfb9e8 100644
--- a/cms/djangoapps/contentstore/views/user.py
+++ b/cms/djangoapps/contentstore/views/user.py
@@ -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()
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
index 7c3b883283..99ce00b891 100644
--- a/cms/djangoapps/models/settings/course_details.py
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -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 = ''
return result
diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py
index a1f5edb153..3b89e2e988 100644
--- a/cms/envs/acceptance.py
+++ b/cms/envs/acceptance.py
@@ -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')
diff --git a/cms/envs/aws.py b/cms/envs/aws.py
index 339425fee5..c2ba51a5f8 100644
--- a/cms/envs/aws.py
+++ b/cms/envs/aws.py
@@ -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", [])
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 40084c20ae..29e99b2551 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -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 ##################################
diff --git a/cms/envs/dev.py b/cms/envs/dev.py
index 0b0a62f05d..42a6f706b6 100644
--- a/cms/envs/dev.py
+++ b/cms/envs/dev.py
@@ -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
diff --git a/cms/envs/dev_dbperf.py b/cms/envs/dev_dbperf.py
new file mode 100644
index 0000000000..2ea131b69e
--- /dev/null
+++ b/cms/envs/dev_dbperf.py
@@ -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
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 4f3b0caee0..ffbf9f5376 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -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')
diff --git a/cms/one_time_startup.py b/cms/one_time_startup.py
index cbd8775d97..4198cf2637 100644
--- a/cms/one_time_startup.py
+++ b/cms/one_time_startup.py
@@ -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
diff --git a/cms/static/coffee/spec/models/settings_grading_spec.coffee b/cms/static/coffee/spec/models/settings_grading_spec.coffee
new file mode 100644
index 0000000000..a8066d21ae
--- /dev/null
+++ b/cms/static/coffee/spec/models/settings_grading_spec.coffee
@@ -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)
diff --git a/cms/static/coffee/spec/models/textbook_spec.coffee b/cms/static/coffee/spec/models/textbook_spec.coffee
index 6e601ecf68..d88e09f57a 100644
--- a/cms/static/coffee/spec/models/textbook_spec.coffee
+++ b/cms/static/coffee/spec/models/textbook_spec.coffee
@@ -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()
diff --git a/cms/static/coffee/spec/models/upload_spec.coffee b/cms/static/coffee/spec/models/upload_spec.coffee
new file mode 100644
index 0000000000..610898745b
--- /dev/null
+++ b/cms/static/coffee/spec/models/upload_spec.coffee
@@ -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'
+ )
diff --git a/cms/static/coffee/spec/views/metadata_edit_spec.coffee b/cms/static/coffee/spec/views/metadata_edit_spec.coffee
index 926e5be315..b3e4567d82 100644
--- a/cms/static/coffee/spec/views/metadata_edit_spec.coffee
+++ b/cms/static/coffee/spec/views/metadata_edit_spec.coffee
@@ -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)
diff --git a/cms/static/coffee/spec/views/textbook_spec.coffee b/cms/static/coffee/spec/views/textbook_spec.coffee
index 981659abfa..ade8c4cb6e 100644
--- a/cms/static/coffee/spec/views/textbook_spec.coffee
+++ b/cms/static/coffee/spec/views/textbook_spec.coffee
@@ -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($("
+
+
@@ -208,6 +214,34 @@ from contentstore import utils
${overview_text()}
+
+
+
+ % if context_course.course_image:
+
+
+
+
+ <% ctx_loc = context_course.location %>
+ ${_("You can manage this image along with all of your other")} ${_("files & uploads")}
+
+ % else:
+
+
+
+ ${_("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)")}
+ % endif
+
+
+
+
+
+ ${_("Please provide a valid path and name to your course image (Note: only JPEG or PNG format supported)")}
+
+
+
+
+
diff --git a/cms/templates/settings_discussions_faculty.html b/cms/templates/settings_discussions_faculty.html
index 1ce96c1ae5..b8f9a24e73 100644
--- a/cms/templates/settings_discussions_faculty.html
+++ b/cms/templates/settings_discussions_faculty.html
@@ -37,7 +37,7 @@ from contentstore import utils
${_("Faculty Members")}
- ${_("Individuals instructing and help with this course")}
+ ${_("Individuals instructing and helping with this course")}
diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html
index f3a4584a26..6dd22482ec 100644
--- a/cms/templates/settings_graders.html
+++ b/cms/templates/settings_graders.html
@@ -97,7 +97,7 @@ from contentstore import utils
-
+
${_("Leeway on due dates")}
diff --git a/cms/templates/textbooks.html b/cms/templates/textbooks.html
index 28349b5436..d55e271858 100644
--- a/cms/templates/textbooks.html
+++ b/cms/templates/textbooks.html
@@ -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"]:
diff --git a/cms/templates/widgets/videoalpha/codemirror-edit.html b/cms/templates/widgets/video/codemirror-edit.html
similarity index 100%
rename from cms/templates/widgets/videoalpha/codemirror-edit.html
rename to cms/templates/widgets/video/codemirror-edit.html
diff --git a/cms/templates/widgets/videoalpha/subtitles.html b/cms/templates/widgets/video/subtitles.html
similarity index 100%
rename from cms/templates/widgets/videoalpha/subtitles.html
rename to cms/templates/widgets/video/subtitles.html
diff --git a/common/lib/xmodule/xmodule/css/videoalpha/common_tabs_edit.scss b/common/djangoapps/course_modes/__init__.py
similarity index 100%
rename from common/lib/xmodule/xmodule/css/videoalpha/common_tabs_edit.scss
rename to common/djangoapps/course_modes/__init__.py
diff --git a/common/djangoapps/course_modes/admin.py b/common/djangoapps/course_modes/admin.py
new file mode 100644
index 0000000000..58c458236a
--- /dev/null
+++ b/common/djangoapps/course_modes/admin.py
@@ -0,0 +1,4 @@
+from ratelimitbackend import admin
+from course_modes.models import CourseMode
+
+admin.site.register(CourseMode)
diff --git a/common/djangoapps/course_modes/migrations/0001_initial.py b/common/djangoapps/course_modes/migrations/0001_initial.py
new file mode 100644
index 0000000000..83e53769a2
--- /dev/null
+++ b/common/djangoapps/course_modes/migrations/0001_initial.py
@@ -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']
\ No newline at end of file
diff --git a/common/djangoapps/course_modes/migrations/0002_auto__add_field_coursemode_currency.py b/common/djangoapps/course_modes/migrations/0002_auto__add_field_coursemode_currency.py
new file mode 100644
index 0000000000..7a25ef2256
--- /dev/null
+++ b/common/djangoapps/course_modes/migrations/0002_auto__add_field_coursemode_currency.py
@@ -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']
\ No newline at end of file
diff --git a/common/djangoapps/course_modes/migrations/0003_auto__add_unique_coursemode_course_id_currency_mode_slug.py b/common/djangoapps/course_modes/migrations/0003_auto__add_unique_coursemode_course_id_currency_mode_slug.py
new file mode 100644
index 0000000000..56a4f28c57
--- /dev/null
+++ b/common/djangoapps/course_modes/migrations/0003_auto__add_unique_coursemode_course_id_currency_mode_slug.py
@@ -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']
\ No newline at end of file
diff --git a/common/djangoapps/course_modes/migrations/__init__.py b/common/djangoapps/course_modes/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py
new file mode 100644
index 0000000000..561c078b3b
--- /dev/null
+++ b/common/djangoapps/course_modes/models.py
@@ -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
diff --git a/common/djangoapps/course_modes/tests.py b/common/djangoapps/course_modes/tests.py
new file mode 100644
index 0000000000..907797bf17
--- /dev/null
+++ b/common/djangoapps/course_modes/tests.py
@@ -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)
diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py
new file mode 100644
index 0000000000..60f00ef0ef
--- /dev/null
+++ b/common/djangoapps/course_modes/views.py
@@ -0,0 +1 @@
+# Create your views here.
diff --git a/common/djangoapps/django_comment_common/models.py b/common/djangoapps/django_comment_common/models.py
index ec722b718a..7878f1b453 100644
--- a/common/djangoapps/django_comment_common/models.py
+++ b/common/djangoapps/django_comment_common/models.py
@@ -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:
diff --git a/common/djangoapps/django_comment_common/tests.py b/common/djangoapps/django_comment_common/tests.py
new file mode 100644
index 0000000000..47790f1e1e
--- /dev/null
+++ b/common/djangoapps/django_comment_common/tests.py
@@ -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())
diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py
index 428119b886..6bb9c38e6f 100644
--- a/common/djangoapps/external_auth/tests/test_shib.py
+++ b/common/djangoapps/external_auth/tests/test_shib.py
@@ -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))
diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py
index 9e50d73b26..712664bf39 100644
--- a/common/djangoapps/static_replace/__init__.py
+++ b/common/djangoapps/static_replace/__init__.py
@@ -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
)
diff --git a/common/djangoapps/static_replace/test/test_static_replace.py b/common/djangoapps/static_replace/test/test_static_replace.py
index f23610e1bd..b1bc05b895 100644
--- a/common/djangoapps/static_replace/test/test_static_replace.py
+++ b/common/djangoapps/static_replace/test/test_static_replace.py
@@ -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')
diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
new file mode 100644
index 0000000000..f1ed5bdef9
--- /dev/null
+++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
@@ -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)
+
diff --git a/common/djangoapps/student/management/commands/create_random_users.py b/common/djangoapps/student/management/commands/create_random_users.py
index 3000c86601..db4bb796cc 100644
--- a/common/djangoapps/student/management/commands/create_random_users.py
+++ b/common/djangoapps/student/management/commands/create_random_users.py
@@ -12,7 +12,7 @@ def create(n, course_id):
for i in range(n):
(user, user_profile, _) = _do_create_account(get_random_post_override())
if course_id is not None:
- CourseEnrollment.objects.create(user=user, course_id=course_id)
+ CourseEnrollment.enroll(user, course_id)
class Command(BaseCommand):
diff --git a/common/djangoapps/student/management/commands/get_grades.py b/common/djangoapps/student/management/commands/get_grades.py
new file mode 100644
index 0000000000..f0d5b5ec5d
--- /dev/null
+++ b/common/djangoapps/student/management/commands/get_grades.py
@@ -0,0 +1,95 @@
+from courseware import grades, courses
+from django.test.client import RequestFactory
+from django.core.management.base import BaseCommand, CommandError
+import os
+from django.contrib.auth.models import User
+from optparse import make_option
+import datetime
+from django.core.handlers.base import BaseHandler
+import csv
+
+
+class RequestMock(RequestFactory):
+ def request(self, **request):
+ "Construct a generic request object."
+ request = RequestFactory.request(self, **request)
+ handler = BaseHandler()
+ handler.load_middleware()
+ for middleware_method in handler._request_middleware:
+ if middleware_method(request):
+ raise Exception("Couldn't create request mock object - "
+ "request middleware returned a response")
+ return request
+
+
+class Command(BaseCommand):
+
+ help = """
+ Generate a list of grades for all students
+ that are enrolled in a course.
+
+ Outputs grades to a csv file.
+
+ Example:
+ sudo -u www-data SERVICE_VARIANT=lms /opt/edx/bin/django-admin.py get_grades \
+ -c MITx/Chi6.00intro/A_Taste_of_Python_Programming -o /tmp/20130813-6.00x.csv \
+ --settings=lms.envs.aws --pythonpath=/opt/wwc/edx-platform
+ """
+
+ option_list = BaseCommand.option_list + (
+ make_option('-c', '--course',
+ metavar='COURSE_ID',
+ dest='course',
+ default=False,
+ help='Course ID for grade distribution'),
+ make_option('-o', '--output',
+ metavar='FILE',
+ dest='output',
+ default=False,
+ help='Filename for grade output'))
+
+ def handle(self, *args, **options):
+ if os.path.exists(options['output']):
+ raise CommandError("File {0} already exists".format(
+ options['output']))
+
+ STATUS_INTERVAL = 100
+ course_id = options['course']
+ print "Fetching enrolled students for {0}".format(course_id)
+ enrolled_students = User.objects.filter(
+ courseenrollment__course_id=course_id).prefetch_related(
+ "groups").order_by('username')
+ factory = RequestMock()
+ request = factory.get('/')
+
+ total = enrolled_students.count()
+ print "Total enrolled: {0}".format(total)
+ course = courses.get_course_by_id(course_id)
+ total = enrolled_students.count()
+ start = datetime.datetime.now()
+ rows = []
+ header = None
+ for count, student in enumerate(enrolled_students):
+ count += 1
+ if count % STATUS_INTERVAL == 0:
+ # Print a status update with an approximation of
+ # how much time is left based on how long the last
+ # interval took
+ diff = datetime.datetime.now() - start
+ timeleft = diff * (total - count) / STATUS_INTERVAL
+ hours, remainder = divmod(timeleft.seconds, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ print "{0}/{1} completed ~{2:02}:{3:02}m remaining".format(
+ count, total, hours, minutes)
+ start = datetime.datetime.now()
+ request.user = student
+ grade = grades.grade(student, request, course)
+ if not header:
+ header = [section['label'] for section in grade[u'section_breakdown']]
+ rows.append(["email", "username"] + header)
+ percents = {section['label']: section['percent'] for section in grade[u'section_breakdown']}
+ row_percents = [percents[label] for label in header]
+ rows.append([student.email, student.username] + row_percents)
+ with open(options['output'], 'wb') as f:
+ writer = csv.writer(f)
+ writer.writerows(rows)
diff --git a/common/djangoapps/student/migrations/0023_add_test_center_registration.py b/common/djangoapps/student/migrations/0023_add_test_center_registration.py
index 4c7de6dcd9..6186f5deef 100644
--- a/common/djangoapps/student/migrations/0023_add_test_center_registration.py
+++ b/common/djangoapps/student/migrations/0023_add_test_center_registration.py
@@ -21,7 +21,7 @@ class Migration(SchemaMigration):
('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)),
('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)),
('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)),
- ('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=1024, blank=True)),
+ ('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=False, max_length=1024, blank=True)),
('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
('upload_status', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)),
@@ -163,7 +163,7 @@ class Migration(SchemaMigration):
'student.testcenterregistration': {
'Meta': {'object_name': 'TestCenterRegistration'},
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
- 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}),
+ 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}),
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
diff --git a/common/djangoapps/student/migrations/0024_add_allow_certificate.py b/common/djangoapps/student/migrations/0024_add_allow_certificate.py
index 56eccf8d70..5753f0176e 100644
--- a/common/djangoapps/student/migrations/0024_add_allow_certificate.py
+++ b/common/djangoapps/student/migrations/0024_add_allow_certificate.py
@@ -93,7 +93,7 @@ class Migration(SchemaMigration):
'student.testcenterregistration': {
'Meta': {'object_name': 'TestCenterRegistration'},
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
- 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}),
+ 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}),
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
diff --git a/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py
index 8ce1d0cda1..1cb21e9b33 100644
--- a/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py
+++ b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py
@@ -94,7 +94,7 @@ class Migration(SchemaMigration):
'student.testcenterregistration': {
'Meta': {'object_name': 'TestCenterRegistration'},
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
- 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}),
+ 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}),
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
diff --git a/common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py b/common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py
new file mode 100644
index 0000000000..23fc476348
--- /dev/null
+++ b/common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+from django.db.utils import DatabaseError
+
+
+class Migration(SchemaMigration):
+ """
+ Remove an unwanted index from environments that have it.
+ This is a one-way migration in that backwards is a no-op and will not undo the removal.
+ This migration is only relevant to dev environments that existed before a migration rewrite
+ which removed the creation of this index.
+ """
+
+ def forwards(self, orm):
+ try:
+ # Removing index on 'TestCenterRegistration', fields ['accommodation_request']
+ db.delete_index('student_testcenterregistration', ['accommodation_request'])
+ except DatabaseError:
+ print "-- skipping delete_index of student_testcenterregistration.accommodation_request (index does not exist)"
+
+
+ def backwards(self, orm):
+ pass
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'student.courseenrollment': {
+ 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'student.courseenrollmentallowed': {
+ 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
+ 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ 'student.pendingemailchange': {
+ 'Meta': {'object_name': 'PendingEmailChange'},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.pendingnamechange': {
+ 'Meta': {'object_name': 'PendingNameChange'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.registration': {
+ 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.testcenterregistration': {
+ 'Meta': {'object_name': 'TestCenterRegistration'},
+ 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
+ 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+ 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+ 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
+ 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+ 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+ 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+ 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+ },
+ 'student.testcenteruser': {
+ 'Meta': {'object_name': 'TestCenterUser'},
+ 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+ 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+ 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
+ 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
+ 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
+ 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
+ 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+ 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
+ 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
+ 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
+ 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+ 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
+ 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+ },
+ 'student.userprofile': {
+ 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
+ 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
+ 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
+ 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+ },
+ 'student.usertestgroup': {
+ 'Meta': {'object_name': 'UserTestGroup'},
+ 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
+ }
+ }
+
+ complete_apps = ['student']
diff --git a/common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py b/common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py
new file mode 100644
index 0000000000..bba8cc6e34
--- /dev/null
+++ b/common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py
@@ -0,0 +1,183 @@
+# -*- 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 'CourseEnrollment.is_active'
+ db.add_column('student_courseenrollment', 'is_active',
+ self.gf('django.db.models.fields.BooleanField')(default=True),
+ keep_default=False)
+
+ # Adding field 'CourseEnrollment.mode'
+ db.add_column('student_courseenrollment', 'mode',
+ self.gf('django.db.models.fields.CharField')(default='honor', max_length=100),
+ keep_default=False)
+
+
+ def backwards(self, orm):
+ # Deleting field 'CourseEnrollment.is_active'
+ db.delete_column('student_courseenrollment', 'is_active')
+
+ # Deleting field 'CourseEnrollment.mode'
+ db.delete_column('student_courseenrollment', 'mode')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'student.courseenrollment': {
+ 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'student.courseenrollmentallowed': {
+ 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
+ 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ 'student.pendingemailchange': {
+ 'Meta': {'object_name': 'PendingEmailChange'},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.pendingnamechange': {
+ 'Meta': {'object_name': 'PendingNameChange'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.registration': {
+ 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.testcenterregistration': {
+ 'Meta': {'object_name': 'TestCenterRegistration'},
+ 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
+ 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+ 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+ 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
+ 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+ 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+ 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+ 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+ },
+ 'student.testcenteruser': {
+ 'Meta': {'object_name': 'TestCenterUser'},
+ 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+ 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+ 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
+ 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
+ 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
+ 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
+ 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+ 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
+ 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
+ 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+ 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
+ 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+ 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
+ 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+ },
+ 'student.userprofile': {
+ 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
+ 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
+ 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
+ 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+ },
+ 'student.usertestgroup': {
+ 'Meta': {'object_name': 'UserTestGroup'},
+ 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
+ }
+ }
+
+ complete_apps = ['student']
\ No newline at end of file
diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 4c41427ca6..6b5897e97d 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -11,11 +11,11 @@ file and check it in at the same time as your model changes. To do that,
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
"""
from datetime import datetime
+from random import randint
import hashlib
import json
import logging
import uuid
-from random import randint
from django.conf import settings
@@ -370,7 +370,7 @@ class TestCenterRegistration(models.Model):
accommodation_code = models.CharField(max_length=64, blank=True)
# store the original text of the accommodation request.
- accommodation_request = models.CharField(max_length=1024, blank=True, db_index=True)
+ accommodation_request = models.CharField(max_length=1024, blank=True, db_index=False)
# time at which edX sent the registration to the test center
uploaded_at = models.DateTimeField(null=True, db_index=True)
@@ -645,16 +645,223 @@ class PendingEmailChange(models.Model):
class CourseEnrollment(models.Model):
+ """
+ Represents a Student's Enrollment record for a single Course. You should
+ generally not manipulate CourseEnrollment objects directly, but use the
+ classmethods provided to enroll, unenroll, or check on the enrollment status
+ of a given student.
+
+ We're starting to consolidate course enrollment logic in this class, but
+ more should be brought in (such as checking against CourseEnrollmentAllowed,
+ checking course dates, user permissions, etc.) This logic is currently
+ scattered across our views.
+ """
user = models.ForeignKey(User)
course_id = models.CharField(max_length=255, db_index=True)
-
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
+ # If is_active is False, then the student is not considered to be enrolled
+ # in the course (is_enrolled() will return False)
+ is_active = models.BooleanField(default=True)
+
+ # Represents the modes that are possible. We'll update this later with a
+ # list of possible values.
+ mode = models.CharField(default="honor", max_length=100)
+
+
class Meta:
unique_together = (('user', 'course_id'),)
+ ordering = ('user', 'course_id')
def __unicode__(self):
- return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created)
+ return (
+ "[CourseEnrollment] {}: {} ({}); active: ({})"
+ ).format(self.user, self.course_id, self.created, self.is_active)
+
+ @classmethod
+ def create_enrollment(cls, user, course_id, mode="honor", is_active=False):
+ """
+ Create an enrollment for a user in a class. By default *this enrollment
+ is not active*. This is useful for when an enrollment needs to go
+ through some sort of approval process before being activated. If you
+ don't need this functionality, just call `enroll()` instead.
+
+ Returns a CoursewareEnrollment object.
+
+ `user` is a Django User object. If it hasn't been saved yet (no `.id`
+ attribute), this method will automatically save it before
+ adding an enrollment for it.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+
+ `mode` is a string specifying what kind of enrollment this is. The
+ default is "honor", meaning honor certificate. Future options
+ may include "audit", "verified_id", etc. Please don't use it
+ until we have these mapped out.
+
+ `is_active` is a boolean. If the CourseEnrollment object has
+ `is_active=False`, then calling
+ `CourseEnrollment.is_enrolled()` for that user/course_id
+ will return False.
+
+ It is expected that this method is called from a method which has already
+ verified the user authentication and access.
+ """
+ # If we're passing in a newly constructed (i.e. not yet persisted) User,
+ # save it to the database so that it can have an ID that we can throw
+ # into our CourseEnrollment object. Otherwise, we'll get an
+ # IntegrityError for having a null user_id.
+ if user.id is None:
+ user.save()
+
+ enrollment, _ = CourseEnrollment.objects.get_or_create(
+ user=user,
+ course_id=course_id,
+ )
+ # In case we're reactivating a deactivated enrollment, or changing the
+ # enrollment mode.
+ if enrollment.mode != mode or enrollment.is_active != is_active:
+ enrollment.mode = mode
+ enrollment.is_active = is_active
+ enrollment.save()
+
+ return enrollment
+
+ @classmethod
+ def enroll(cls, user, course_id, mode="honor"):
+ """
+ Enroll a user in a course. This saves immediately.
+
+ Returns a CoursewareEnrollment object.
+
+ `user` is a Django User object. If it hasn't been saved yet (no `.id`
+ attribute), this method will automatically save it before
+ adding an enrollment for it.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+
+ `mode` is a string specifying what kind of enrollment this is. The
+ default is "honor", meaning honor certificate. Future options
+ may include "audit", "verified_id", etc. Please don't use it
+ until we have these mapped out.
+
+ It is expected that this method is called from a method which has already
+ verified the user authentication and access.
+ """
+ return cls.create_enrollment(user, course_id, mode, is_active=True)
+
+ @classmethod
+ def enroll_by_email(cls, email, course_id, mode="honor", ignore_errors=True):
+ """
+ Enroll a user in a course given their email. This saves immediately.
+
+ Note that enrolling by email is generally done in big batches and the
+ error rate is high. For that reason, we supress User lookup errors by
+ default.
+
+ Returns a CoursewareEnrollment object. If the User does not exist and
+ `ignore_errors` is set to `True`, it will return None.
+
+ `email` Email address of the User to add to enroll in the course.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+
+ `mode` is a string specifying what kind of enrollment this is. The
+ default is "honor", meaning honor certificate. Future options
+ may include "audit", "verified_id", etc. Please don't use it
+ until we have these mapped out.
+
+ `ignore_errors` is a boolean indicating whether we should suppress
+ `User.DoesNotExist` errors (returning None) or let it
+ bubble up.
+
+ It is expected that this method is called from a method which has already
+ verified the user authentication and access.
+ """
+ try:
+ user = User.objects.get(email=email)
+ return cls.enroll(user, course_id, mode)
+ except User.DoesNotExist:
+ err_msg = u"Tried to enroll email {} into course {}, but user not found"
+ log.error(err_msg.format(email, course_id))
+ if ignore_errors:
+ return None
+ raise
+
+ @classmethod
+ def unenroll(cls, user, course_id):
+ """
+ Remove the user from a given course. If the relevant `CourseEnrollment`
+ object doesn't exist, we log an error but don't throw an exception.
+
+ `user` is a Django User object. If it hasn't been saved yet (no `.id`
+ attribute), this method will automatically save it before
+ adding an enrollment for it.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+ """
+ try:
+ record = CourseEnrollment.objects.get(user=user, course_id=course_id)
+ record.is_active = False
+ record.save()
+ except cls.DoesNotExist:
+ log.error("Tried to unenroll student {} from {} but they were not enrolled")
+
+ @classmethod
+ def unenroll_by_email(cls, email, course_id):
+ """
+ Unenroll a user from a course given their email. This saves immediately.
+ User lookup errors are logged but will not throw an exception.
+
+ `email` Email address of the User to unenroll from the course.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+ """
+ try:
+ user = User.objects.get(email=email)
+ return cls.unenroll(user, course_id)
+ except User.DoesNotExist:
+ err_msg = u"Tried to unenroll email {} from course {}, but user not found"
+ log.error(err_msg.format(email, course_id))
+
+ @classmethod
+ def is_enrolled(cls, user, course_id):
+ """
+ Remove the user from a given course. If the relevant `CourseEnrollment`
+ object doesn't exist, we log an error but don't throw an exception.
+
+ Returns True if the user is enrolled in the course (the entry must exist
+ and it must have `is_active=True`). Otherwise, returns False.
+
+ `user` is a Django User object. If it hasn't been saved yet (no `.id`
+ attribute), this method will automatically save it before
+ adding an enrollment for it.
+
+ `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
+ """
+ try:
+ record = CourseEnrollment.objects.get(user=user, course_id=course_id)
+ return record.is_active
+ except cls.DoesNotExist:
+ return False
+
+ @classmethod
+ def enrollments_for_user(cls, user):
+ return CourseEnrollment.objects.filter(user=user, is_active=1)
+
+ def activate(self):
+ """Makes this `CourseEnrollment` record active. Saves immediately."""
+ if not self.is_active:
+ self.is_active = True
+ self.save()
+
+ def deactivate(self):
+ """Makes this `CourseEnrollment` record inactive. Saves immediately. An
+ inactive record means that the student is not enrolled in this course.
+ """
+ if self.is_active:
+ self.is_active = False
+ self.save()
class CourseEnrollmentAllowed(models.Model):
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index 513216ba17..397816ec00 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -21,9 +21,8 @@ from django.utils.http import int_to_base36
from mock import Mock, patch
from textwrap import dedent
-from student.models import unique_id_for_user
+from student.models import unique_id_for_user, CourseEnrollment
from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper
-from student.views import enroll_in_course, is_enrolled_in_course
from student.tests.factories import UserFactory
from student.tests.test_email import mock_render_to_string
COURSE_1 = 'edX/toy/2012_Fall'
@@ -209,12 +208,127 @@ class CourseEndingTest(TestCase):
class EnrollInCourseTest(TestCase):
- """ Tests the helper method for enrolling a user in a class """
+ """Tests enrolling and unenrolling in courses."""
- def test_enroll_in_course(self):
+ def test_enrollment(self):
user = User.objects.create_user("joe", "joe@joe.com", "password")
- user.save()
- course_id = "course_id"
- self.assertFalse(is_enrolled_in_course(user, course_id))
- enroll_in_course(user, course_id)
- self.assertTrue(is_enrolled_in_course(user, course_id))
+ course_id = "edX/Test101/2013"
+
+ # Test basic enrollment
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+ CourseEnrollment.enroll(user, course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Enrolling them again should be harmless
+ CourseEnrollment.enroll(user, course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Now unenroll the user
+ CourseEnrollment.unenroll(user, course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Unenrolling them again should also be harmless
+ CourseEnrollment.unenroll(user, course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # The enrollment record should still exist, just be inactive
+ enrollment_record = CourseEnrollment.objects.get(
+ user=user,
+ course_id=course_id
+ )
+ self.assertFalse(enrollment_record.is_active)
+
+ def test_enrollment_non_existent_user(self):
+ # Testing enrollment of newly unsaved user (i.e. no database entry)
+ user = User(username="rusty", email="rusty@fake.edx.org")
+ course_id = "edX/Test101/2013"
+
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Unenroll does nothing
+ CourseEnrollment.unenroll(user, course_id)
+
+ # Implicit save() happens on new User object when enrolling, so this
+ # should still work
+ CourseEnrollment.enroll(user, course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ def test_enrollment_by_email(self):
+ user = User.objects.create(username="jack", email="jack@fake.edx.org")
+ course_id = "edX/Test101/2013"
+
+ CourseEnrollment.enroll_by_email("jack@fake.edx.org", course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # This won't throw an exception, even though the user is not found
+ self.assertIsNone(
+ CourseEnrollment.enroll_by_email("not_jack@fake.edx.org", course_id)
+ )
+
+ self.assertRaises(
+ User.DoesNotExist,
+ CourseEnrollment.enroll_by_email,
+ "not_jack@fake.edx.org",
+ course_id,
+ ignore_errors=False
+ )
+
+ # Now unenroll them by email
+ CourseEnrollment.unenroll_by_email("jack@fake.edx.org", course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Harmless second unenroll
+ CourseEnrollment.unenroll_by_email("jack@fake.edx.org", course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Unenroll on non-existent user shouldn't throw an error
+ CourseEnrollment.unenroll_by_email("not_jack@fake.edx.org", course_id)
+
+ def test_enrollment_multiple_classes(self):
+ user = User(username="rusty", email="rusty@fake.edx.org")
+ course_id1 = "edX/Test101/2013"
+ course_id2 = "MITx/6.003z/2012"
+
+ CourseEnrollment.enroll(user, course_id1)
+ CourseEnrollment.enroll(user, course_id2)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id1))
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id2))
+
+ CourseEnrollment.unenroll(user, course_id1)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id1))
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id2))
+
+ CourseEnrollment.unenroll(user, course_id2)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id1))
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id2))
+
+ def test_activation(self):
+ user = User.objects.create(username="jack", email="jack@fake.edx.org")
+ course_id = "edX/Test101/2013"
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Creating an enrollment doesn't actually enroll a student
+ # (calling CourseEnrollment.enroll() would have)
+ enrollment = CourseEnrollment.create_enrollment(user, course_id)
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Until you explicitly activate it
+ enrollment.activate()
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Activating something that's already active does nothing
+ enrollment.activate()
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Now deactive
+ enrollment.deactivate()
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # Deactivating something that's already inactive does nothing
+ enrollment.deactivate()
+ self.assertFalse(CourseEnrollment.is_enrolled(user, course_id))
+
+ # A deactivated enrollment should be activated if enroll() is called
+ # for that user/course_id combination
+ CourseEnrollment.enroll(user, course_id)
+ self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 0b061f5a94..4d59b5cc66 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -95,6 +95,7 @@ def index(request, extra_context={}, user=None):
courses = sort_by_announcement(courses)
context = {'courses': courses}
+
context.update(extra_context)
return render_to_response('index.html', context)
@@ -254,13 +255,12 @@ def register_user(request, extra_context=None):
@ensure_csrf_cookie
def dashboard(request):
user = request.user
- enrollments = CourseEnrollment.objects.filter(user=user)
# Build our courses list for the user, but ignore any courses that no longer
# exist (because the course IDs have changed). Still, we don't delete those
# enrollments, because it could have been a data push snafu.
courses = []
- for enrollment in enrollments:
+ for enrollment in CourseEnrollment.enrollments_for_user(user):
try:
courses.append(course_from_id(enrollment.course_id))
except ItemNotFoundError:
@@ -377,18 +377,13 @@ def change_enrollment(request):
"course:{0}".format(course_num),
"run:{0}".format(run)])
- try:
- enroll_in_course(user, course.id)
- except IntegrityError:
- # If we've already created this enrollment in a separate transaction,
- # then just continue
- pass
+ CourseEnrollment.enroll(user, course.id)
+
return HttpResponse()
elif action == "unenroll":
try:
- enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id)
- enrollment.delete()
+ CourseEnrollment.unenroll(user, course_id)
org, course_num, run = course_id.split("/")
statsd.increment("common.student.unenrollment",
@@ -402,30 +397,10 @@ def change_enrollment(request):
else:
return HttpResponseBadRequest(_("Enrollment action is invalid"))
-
-def enroll_in_course(user, course_id):
- """
- Helper method to enroll a user in a particular class.
-
- It is expected that this method is called from a method which has already
- verified the user authentication and access.
- """
- CourseEnrollment.objects.get_or_create(user=user, course_id=course_id)
-
-
-def is_enrolled_in_course(user, course_id):
- """
- Helper method that returns whether or not the user is enrolled in a particular course.
- """
- return CourseEnrollment.objects.filter(user=user, course_id=course_id).count() > 0
-
-
@ensure_csrf_cookie
def accounts_login(request, error=""):
-
return render_to_response('login.html', {'error': error})
-
# Need different levels of logging
@ensure_csrf_cookie
def login_user(request, error=""):
@@ -1008,13 +983,21 @@ def activate_account(request, key):
ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email)
for cea in ceas:
if cea.auto_enroll:
- course_id = cea.course_id
- _enrollment, _created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id)
+ CourseEnrollment.enroll(student[0], cea.course_id)
- resp = render_to_response("registration/activation_complete.html", {'user_logged_in': user_logged_in, 'already_active': already_active})
+ resp = render_to_response(
+ "registration/activation_complete.html",
+ {
+ 'user_logged_in': user_logged_in,
+ 'already_active': already_active
+ }
+ )
return resp
if len(r) == 0:
- return render_to_response("registration/activation_invalid.html", {'csrf': csrf(request)['csrf_token']})
+ return render_to_response(
+ "registration/activation_invalid.html",
+ {'csrf': csrf(request)['csrf_token']}
+ )
return HttpResponse(_("Unknown error. Please e-mail us to let us know how it happened."))
@@ -1037,7 +1020,11 @@ def password_reset(request):
'error': _('Invalid e-mail or user')}))
-def password_reset_confirm_wrapper(request, uidb36=None, token=None):
+def password_reset_confirm_wrapper(
+ request,
+ uidb36=None,
+ token=None,
+):
''' A wrapper around django.contrib.auth.views.password_reset_confirm.
Needed because we want to set the user as active at this step.
'''
@@ -1049,7 +1036,12 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
user.save()
except (ValueError, User.DoesNotExist):
pass
- return password_reset_confirm(request, uidb36=uidb36, token=token)
+ # we also want to pass settings.PLATFORM_NAME in as extra_context
+
+ extra_context = {"platform_name": settings.PLATFORM_NAME}
+ return password_reset_confirm(
+ request, uidb36=uidb36, token=token, extra_context=extra_context
+ )
def reactivation_email_for_user(user):
diff --git a/common/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py
index c2bf2bbbf3..cf53aa4f69 100644
--- a/common/djangoapps/terrain/browser.py
+++ b/common/djangoapps/terrain/browser.py
@@ -11,6 +11,10 @@ from logging import getLogger
from django.core.management import call_command
from django.conf import settings
from selenium.common.exceptions import WebDriverException
+from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
+from requests import put
+from base64 import encodestring
+from json import dumps
# Let the LMS and CMS do their one-time setup
# For example, setting up mongo caches
@@ -42,43 +46,93 @@ LOGGER.info("Loading the lettuce acceptance testing terrain file...")
MAX_VALID_BROWSER_ATTEMPTS = 20
+def get_username_and_key():
+ """
+ Returns the Sauce Labs username and access ID as set by environment variables
+ """
+ return {"username": settings.SAUCE.get('USERNAME'), "access-key": settings.SAUCE.get('ACCESS_ID')}
+
+
+def set_job_status(jobid, passed=True):
+ """
+ Sets the job status on sauce labs
+ """
+ body_content = dumps({"passed": passed})
+ config = get_username_and_key()
+ base64string = encodestring('{}:{}'.format(config['username'], config['access-key']))[:-1]
+ result = put('http://saucelabs.com/rest/v1/{}/jobs/{}'.format(config['username'], world.jobid),
+ data=body_content,
+ headers={"Authorization": "Basic {}".format(base64string)})
+ return result.status_code == 200
+
+
+def make_desired_capabilities():
+ """
+ Returns a DesiredCapabilities object corresponding to the environment sauce parameters
+ """
+ desired_capabilities = settings.SAUCE.get('BROWSER', DesiredCapabilities.CHROME)
+ desired_capabilities['platform'] = settings.SAUCE.get('PLATFORM')
+ desired_capabilities['version'] = settings.SAUCE.get('VERSION')
+ desired_capabilities['device-type'] = settings.SAUCE.get('DEVICE')
+ desired_capabilities['name'] = settings.SAUCE.get('SESSION')
+ desired_capabilities['build'] = settings.SAUCE.get('BUILD')
+ desired_capabilities['video-upload-on-pass'] = False
+ desired_capabilities['sauce-advisor'] = False
+ desired_capabilities['record-screenshots'] = False
+ desired_capabilities['selenium-version'] = "2.34.0"
+ desired_capabilities['max-duration'] = 3600
+ desired_capabilities['public'] = 'public restricted'
+ return desired_capabilities
+
+
@before.harvest
def initial_setup(server):
"""
Launch the browser once before executing the tests.
"""
- browser_driver = getattr(settings, 'LETTUCE_BROWSER', 'chrome')
+ world.absorb(settings.SAUCE.get('SAUCE_ENABLED'), 'SAUCE_ENABLED')
- # There is an issue with ChromeDriver2 r195627 on Ubuntu
- # in which we sometimes get an invalid browser session.
- # This is a work-around to ensure that we get a valid session.
- success = False
- num_attempts = 0
- while (not success) and num_attempts < MAX_VALID_BROWSER_ATTEMPTS:
+ if not world.SAUCE_ENABLED:
+ browser_driver = getattr(settings, 'LETTUCE_BROWSER', 'chrome')
- # Get a browser session
- world.browser = Browser(browser_driver)
+ # There is an issue with ChromeDriver2 r195627 on Ubuntu
+ # in which we sometimes get an invalid browser session.
+ # This is a work-around to ensure that we get a valid session.
+ success = False
+ num_attempts = 0
+ while (not success) and num_attempts < MAX_VALID_BROWSER_ATTEMPTS:
+ world.browser = Browser(browser_driver)
- # Try to visit the main page
- # If the browser session is invalid, this will
- # raise a WebDriverException
- try:
- world.visit('/')
+ # Try to visit the main page
+ # If the browser session is invalid, this will
+ # raise a WebDriverException
+ try:
+ world.visit('/')
- except WebDriverException:
- world.browser.quit()
- num_attempts += 1
+ except WebDriverException:
+ world.browser.quit()
+ num_attempts += 1
- else:
- success = True
+ else:
+ success = True
- # If we were unable to get a valid session within the limit of attempts,
- # then we cannot run the tests.
- if not success:
- raise IOError("Could not acquire valid {driver} browser session.".format(driver=browser_driver))
+ # If we were unable to get a valid session within the limit of attempts,
+ # then we cannot run the tests.
+ if not success:
+ raise IOError("Could not acquire valid {driver} browser session.".format(driver=browser_driver))
- # Set the browser size to 1280x1024
- world.browser.driver.set_window_size(1280, 1024)
+ world.browser.driver.set_window_size(1280, 1024)
+
+ else:
+ config = get_username_and_key()
+ world.browser = Browser(
+ 'remote',
+ url="http://{}:{}@ondemand.saucelabs.com:80/wd/hub".format(config['username'], config['access-key']),
+ **make_desired_capabilities()
+ )
+ world.browser.driver.implicitly_wait(30)
+
+ world.absorb(world.browser.driver.session_id, 'jobid')
@before.each_scenario
@@ -97,7 +151,6 @@ def clear_data(scenario):
world.spew('scenario_dict')
-
@after.each_scenario
def reset_databases(scenario):
'''
@@ -128,4 +181,6 @@ def teardown_browser(total):
"""
Quit the browser after executing the tests.
"""
+ if world.SAUCE_ENABLED:
+ set_job_status(world.jobid, total.scenarios_ran == total.scenarios_passed)
world.browser.quit()
diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py
index afef8bf2e1..eca3290080 100644
--- a/common/djangoapps/terrain/course_helpers.py
+++ b/common/djangoapps/terrain/course_helpers.py
@@ -54,7 +54,7 @@ def register_by_course_id(course_id, is_staff=False):
if is_staff:
u.is_staff = True
u.save()
- CourseEnrollment.objects.get_or_create(user=u, course_id=course_id)
+ CourseEnrollment.enroll(u, course_id)
@world.absorb
diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py
index 9cf2aeda49..f13b3ff932 100644
--- a/common/djangoapps/terrain/steps.py
+++ b/common/djangoapps/terrain/steps.py
@@ -99,7 +99,7 @@ def i_am_logged_in_user(step):
@step('I am not logged in$')
def i_am_not_logged_in(step):
- world.browser.cookies.delete()
+ world.visit('logout')
@step('I am staff for course "([^"]*)"$')
@@ -138,10 +138,13 @@ def should_have_link_with_path_and_text(step, path, text):
@step(r'should( not)? see "(.*)" (?:somewhere|anywhere) (?:in|on) (?:the|this) page')
def should_see_in_the_page(step, doesnt_appear, text):
+ multiplier = 1
+ if world.SAUCE_ENABLED:
+ multiplier = 2
if doesnt_appear:
- assert world.browser.is_text_not_present(text, wait_time=5)
+ assert world.browser.is_text_not_present(text, wait_time=5*multiplier)
else:
- assert world.browser.is_text_present(text, wait_time=5)
+ assert world.browser.is_text_present(text, wait_time=5*multiplier)
@step('I am logged in$')
@@ -150,7 +153,7 @@ def i_am_logged_in(step):
world.log_in(username='robot', password='test')
world.browser.visit(django_url('/'))
# You should not see the login link
- assert_equals(world.browser.find_by_css('a#login'), [])
+ assert world.is_css_not_present('a#login')
@step(u'I am an edX user$')
diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py
index dd40b5139d..16fe12371b 100644
--- a/common/djangoapps/xmodule_modifiers.py
+++ b/common/djangoapps/xmodule_modifiers.py
@@ -76,7 +76,7 @@ def replace_course_urls(get_html, course_id):
return _get_html
-def replace_static_urls(get_html, data_dir, course_namespace=None):
+def replace_static_urls(get_html, data_dir, course_id=None, static_asset_path=''):
"""
Updates the supplied module with a new get_html function that wraps
the old get_html function and substitutes urls of the form /static/...
@@ -85,7 +85,7 @@ def replace_static_urls(get_html, data_dir, course_namespace=None):
@wraps(get_html)
def _get_html():
- return static_replace.replace_static_urls(get_html(), data_dir, course_namespace)
+ return static_replace.replace_static_urls(get_html(), data_dir, course_id, static_asset_path=static_asset_path)
return _get_html
diff --git a/common/lib/calc/calc.py b/common/lib/calc/calc.py
index bbfd9545f6..ab300f121b 100644
--- a/common/lib/calc/calc.py
+++ b/common/lib/calc/calc.py
@@ -4,129 +4,100 @@ Parser and evaluator for FormulaResponse and NumericalResponse
Uses pyparsing to parse. Main function as of now is evaluator().
"""
-import copy
import math
import operator
-import re
-
+import numbers
import numpy
import scipy.constants
import calcfunctions
-# have numpy raise errors on functions outside its domain
-# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
-numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
+from pyparsing import (
+ Word, Literal, CaselessLiteral, ZeroOrMore, MatchFirst, Optional, Forward,
+ Group, ParseResults, stringEnd, Suppress, Combine, alphas, nums, alphanums
+)
-from pyparsing import (Word, nums, Literal,
- ZeroOrMore, MatchFirst,
- Optional, Forward,
- CaselessLiteral,
- stringEnd, Suppress, Combine)
-
-DEFAULT_FUNCTIONS = {'sin': numpy.sin,
- 'cos': numpy.cos,
- 'tan': numpy.tan,
- 'sec': calcfunctions.sec,
- 'csc': calcfunctions.csc,
- 'cot': calcfunctions.cot,
- 'sqrt': numpy.sqrt,
- 'log10': numpy.log10,
- 'log2': numpy.log2,
- 'ln': numpy.log,
- 'exp': numpy.exp,
- 'arccos': numpy.arccos,
- 'arcsin': numpy.arcsin,
- 'arctan': numpy.arctan,
- 'arcsec': calcfunctions.arcsec,
- 'arccsc': calcfunctions.arccsc,
- 'arccot': calcfunctions.arccot,
- 'abs': numpy.abs,
- 'fact': math.factorial,
- 'factorial': math.factorial,
- 'sinh': numpy.sinh,
- 'cosh': numpy.cosh,
- 'tanh': numpy.tanh,
- 'sech': calcfunctions.sech,
- 'csch': calcfunctions.csch,
- 'coth': calcfunctions.coth,
- 'arcsinh': numpy.arcsinh,
- 'arccosh': numpy.arccosh,
- 'arctanh': numpy.arctanh,
- 'arcsech': calcfunctions.arcsech,
- 'arccsch': calcfunctions.arccsch,
- 'arccoth': calcfunctions.arccoth
- }
-DEFAULT_VARIABLES = {'i': numpy.complex(0, 1),
- 'j': numpy.complex(0, 1),
- 'e': numpy.e,
- 'pi': numpy.pi,
- 'k': scipy.constants.k,
- 'c': scipy.constants.c,
- 'T': 298.15,
- 'q': scipy.constants.e
- }
+DEFAULT_FUNCTIONS = {
+ 'sin': numpy.sin,
+ 'cos': numpy.cos,
+ 'tan': numpy.tan,
+ 'sec': calcfunctions.sec,
+ 'csc': calcfunctions.csc,
+ 'cot': calcfunctions.cot,
+ 'sqrt': numpy.sqrt,
+ 'log10': numpy.log10,
+ 'log2': numpy.log2,
+ 'ln': numpy.log,
+ 'exp': numpy.exp,
+ 'arccos': numpy.arccos,
+ 'arcsin': numpy.arcsin,
+ 'arctan': numpy.arctan,
+ 'arcsec': calcfunctions.arcsec,
+ 'arccsc': calcfunctions.arccsc,
+ 'arccot': calcfunctions.arccot,
+ 'abs': numpy.abs,
+ 'fact': math.factorial,
+ 'factorial': math.factorial,
+ 'sinh': numpy.sinh,
+ 'cosh': numpy.cosh,
+ 'tanh': numpy.tanh,
+ 'sech': calcfunctions.sech,
+ 'csch': calcfunctions.csch,
+ 'coth': calcfunctions.coth,
+ 'arcsinh': numpy.arcsinh,
+ 'arccosh': numpy.arccosh,
+ 'arctanh': numpy.arctanh,
+ 'arcsech': calcfunctions.arcsech,
+ 'arccsch': calcfunctions.arccsch,
+ 'arccoth': calcfunctions.arccoth
+}
+DEFAULT_VARIABLES = {
+ 'i': numpy.complex(0, 1),
+ 'j': numpy.complex(0, 1),
+ 'e': numpy.e,
+ 'pi': numpy.pi,
+ 'k': scipy.constants.k, # Boltzmann: 1.3806488e-23 (Joules/Kelvin)
+ 'c': scipy.constants.c, # Light Speed: 2.998e8 (m/s)
+ 'T': 298.15, # 0 deg C = T Kelvin
+ 'q': scipy.constants.e # Fund. Charge: 1.602176565e-19 (Coulombs)
+}
# We eliminated the following extreme suffixes:
-# P (1e15), E (1e18), Z (1e21), Y (1e24),
-# f (1e-15), a (1e-18), z (1e-21), y (1e-24)
-# since they're rarely used, and potentially
-# confusing. They may also conflict with variables if we ever allow e.g.
-# 5R instead of 5*R
-SUFFIXES = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12,
- 'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12}
+# P (1e15), E (1e18), Z (1e21), Y (1e24),
+# f (1e-15), a (1e-18), z (1e-21), y (1e-24)
+# since they're rarely used, and potentially confusing.
+# They may also conflict with variables if we ever allow e.g.
+# 5R instead of 5*R
+SUFFIXES = {
+ '%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12,
+ 'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12
+}
class UndefinedVariable(Exception):
"""
- Used to indicate the student input of a variable, which was unused by the
- instructor.
+ Indicate when a student inputs a variable which was not expected.
"""
pass
-def check_variables(string, variables):
- """
- Confirm the only variables in string are defined.
-
- Otherwise, raise an UndefinedVariable containing all bad variables.
-
- Pyparsing uses a left-to-right parser, which makes a more
- elegant approach pretty hopeless.
- """
- general_whitespace = re.compile('[^\\w]+') # TODO consider non-ascii
- # List of all alnums in string
- possible_variables = re.split(general_whitespace, string)
- bad_variables = []
- for var in possible_variables:
- if len(var) == 0:
- continue
- if var[0].isdigit(): # Skip things that begin with numbers
- continue
- if var not in variables:
- bad_variables.append(var)
- if len(bad_variables) > 0:
- raise UndefinedVariable(' '.join(bad_variables))
-
-
def lower_dict(input_dict):
"""
- takes each key in the dict and makes it lowercase, still mapping to the
- same value.
+ Convert all keys in a dictionary to lowercase; keep their original values.
- keep in mind that it is possible (but not useful?) to define different
+ Keep in mind that it is possible (but not useful?) to define different
variables that have the same lowercase representation. It would be hard to
tell which is used in the final dict and which isn't.
"""
return {k.lower(): v for k, v in input_dict.iteritems()}
-# The following few functions define parse actions, which are run on lists of
-# results from each parse component. They convert the strings and (previously
+# The following few functions define evaluation actions, which are run on lists
+# of results from each parse component. They convert the strings and (previously
# calculated) numbers into the number that component represents.
def super_float(text):
"""
- Like float, but with si extensions. 1k goes to 1000
+ Like float, but with SI extensions. 1k goes to 1000.
"""
if text[-1] in SUFFIXES:
return float(text[:-1]) * SUFFIXES[text[-1]]
@@ -134,168 +105,314 @@ def super_float(text):
return float(text)
-def number_parse_action(parse_result):
+def eval_number(parse_result):
"""
- Create a float out of its string parts
+ Create a float out of its string parts.
- e.g. [ '7', '.', '13' ] -> [ 7.13 ]
- Calls super_float above
+ e.g. [ '7.13', 'e', '3' ] -> 7130
+ Calls super_float above.
"""
return super_float("".join(parse_result))
-def exp_parse_action(parse_result):
+def eval_atom(parse_result):
"""
- Take a list of numbers and exponentiate them, right to left
+ Return the value wrapped by the atom.
- e.g. [ 3, 2, 3 ] (which is 3^2^3 = 3^(2^3)) -> 6561
+ In the case of parenthesis, ignore them.
"""
- # pyparsing.ParseResults doesn't play well with reverse()
- parse_result = reversed(parse_result)
- # the result of an exponentiation is called a power
+ # Find first number in the list
+ result = next(k for k in parse_result if isinstance(k, numbers.Number))
+ return result
+
+
+def eval_power(parse_result):
+ """
+ Take a list of numbers and exponentiate them, right to left.
+
+ e.g. [ 2, 3, 2 ] -> 2^3^2 = 2^(3^2) -> 512
+ (not to be interpreted (2^3)^2 = 64)
+ """
+ # `reduce` will go from left to right; reverse the list.
+ parse_result = reversed(
+ [k for k in parse_result
+ if isinstance(k, numbers.Number)] # Ignore the '^' marks.
+ )
+ # Having reversed it, raise `b` to the power of `a`.
power = reduce(lambda a, b: b ** a, parse_result)
return power
-def parallel(parse_result):
+def eval_parallel(parse_result):
"""
- Compute numbers according to the parallel resistors operator
+ Compute numbers according to the parallel resistors operator.
BTW it is commutative. Its formula is given by
out = 1 / (1/in1 + 1/in2 + ...)
- e.g. [ 1, 2 ] => 2/3
+ e.g. [ 1, 2 ] -> 2/3
- Return NaN if there is a zero among the inputs
+ Return NaN if there is a zero among the inputs.
"""
- # convert from pyparsing.ParseResults, which doesn't support '0 in parse_result'
- parse_result = parse_result.asList()
if len(parse_result) == 1:
return parse_result[0]
if 0 in parse_result:
return float('nan')
- reciprocals = [1. / e for e in parse_result]
+ reciprocals = [1. / e for e in parse_result
+ if isinstance(e, numbers.Number)]
return 1. / sum(reciprocals)
-def sum_parse_action(parse_result):
+def eval_sum(parse_result):
"""
- Add the inputs
+ Add the inputs, keeping in mind their sign.
[ 1, '+', 2, '-', 3 ] -> 0
- Allow a leading + or -
+ Allow a leading + or -.
"""
total = 0.0
current_op = operator.add
for token in parse_result:
- if token is '+':
+ if token == '+':
current_op = operator.add
- elif token is '-':
+ elif token == '-':
current_op = operator.sub
else:
total = current_op(total, token)
return total
-def prod_parse_action(parse_result):
+def eval_product(parse_result):
"""
- Multiply the inputs
+ Multiply the inputs.
- [ 1, '*', 2, '/', 3 ] => 0.66
+ [ 1, '*', 2, '/', 3 ] -> 0.66
"""
prod = 1.0
current_op = operator.mul
for token in parse_result:
- if token is '*':
+ if token == '*':
current_op = operator.mul
- elif token is '/':
+ elif token == '/':
current_op = operator.truediv
else:
prod = current_op(prod, token)
return prod
-def evaluator(variables, functions, string, cs=False):
+def add_defaults(variables, functions, case_sensitive):
"""
- Evaluate an expression. Variables are passed as a dictionary
- from string to value. Unary functions are passed as a dictionary
- from string to function. Variables must be floats.
- cs: Case sensitive
-
+ Create dictionaries with both the default and user-defined variables.
"""
-
- all_variables = copy.copy(DEFAULT_VARIABLES)
- all_functions = copy.copy(DEFAULT_FUNCTIONS)
+ all_variables = dict(DEFAULT_VARIABLES)
+ all_functions = dict(DEFAULT_FUNCTIONS)
all_variables.update(variables)
all_functions.update(functions)
- if not cs:
- string_cs = string.lower()
- all_functions = lower_dict(all_functions)
+ if not case_sensitive:
all_variables = lower_dict(all_variables)
- CasedLiteral = CaselessLiteral
- else:
- string_cs = string
- CasedLiteral = Literal
+ all_functions = lower_dict(all_functions)
- check_variables(string_cs, set(all_variables.keys() + all_functions.keys()))
+ return (all_variables, all_functions)
- if string.strip() == "":
+
+def evaluator(variables, functions, math_expr, case_sensitive=False):
+ """
+ Evaluate an expression; that is, take a string of math and return a float.
+
+ -Variables are passed as a dictionary from string to value. They must be
+ python numbers.
+ -Unary functions are passed as a dictionary from string to function.
+ """
+ # No need to go further.
+ if math_expr.strip() == "":
return float('nan')
- # SI suffixes and percent
- number_suffix = MatchFirst([Literal(k) for k in SUFFIXES.keys()])
- plus_minus = Literal('+') | Literal('-')
- times_div = Literal('*') | Literal('/')
+ # Parse the tree.
+ math_interpreter = ParseAugmenter(math_expr, case_sensitive)
+ math_interpreter.parse_algebra()
- number_part = Word(nums)
+ # Get our variables together.
+ all_variables, all_functions = add_defaults(variables, functions, case_sensitive)
- # 0.33 or 7 or .34 or 16.
- inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
- # by default pyparsing allows spaces between tokens--Combine prevents that
- inner_number = Combine(inner_number)
+ # ...and check them
+ math_interpreter.check_variables(all_variables, all_functions)
- # 0.33k or -17
- number = (inner_number
- + Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part)
- + Optional(number_suffix))
- number.setParseAction(number_parse_action) # Convert to number
+ # Create a recursion to evaluate the tree.
+ if case_sensitive:
+ casify = lambda x: x
+ else:
+ casify = lambda x: x.lower() # Lowercase for case insens.
- # Predefine recursive variables
- expr = Forward()
+ evaluate_actions = {
+ 'number': eval_number,
+ 'variable': lambda x: all_variables[casify(x[0])],
+ 'function': lambda x: all_functions[casify(x[0])](x[1]),
+ 'atom': eval_atom,
+ 'power': eval_power,
+ 'parallel': eval_parallel,
+ 'product': eval_product,
+ 'sum': eval_sum
+ }
- # Handle variables passed in.
- # E.g. if we have {'R':0.5}, we make the substitution.
- # We sort the list so that var names (like "e2") match before
- # mathematical constants (like "e"). This is kind of a hack.
- all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True)
- varnames = MatchFirst([CasedLiteral(k) for k in all_variables_keys])
- varnames.setParseAction(
- lambda x: [all_variables[k] for k in x]
- )
+ return math_interpreter.reduce_tree(evaluate_actions)
- # if all_variables were empty, then pyparsing wants
- # varnames = NoMatch()
- # this is not the case, as all_variables contains the defaults
- # Same thing for functions.
- all_functions_keys = sorted(all_functions.keys(), key=len, reverse=True)
- funcnames = MatchFirst([CasedLiteral(k) for k in all_functions_keys])
- function = funcnames + Suppress("(") + expr + Suppress(")")
- function.setParseAction(
- lambda x: [all_functions[x[0]](x[1])]
- )
+class ParseAugmenter(object):
+ """
+ Holds the data for a particular parse.
- atom = number | function | varnames | Suppress("(") + expr + Suppress(")")
+ Retains the `math_expr` and `case_sensitive` so they needn't be passed
+ around method to method.
+ Eventually holds the parse tree and sets of variables as well.
+ """
+ def __init__(self, math_expr, case_sensitive=False):
+ """
+ Create the ParseAugmenter for a given math expression string.
- # Do the following in the correct order to preserve order of operation
- pow_term = atom + ZeroOrMore(Suppress("^") + atom)
- pow_term.setParseAction(exp_parse_action) # 7^6
- par_term = pow_term + ZeroOrMore(Suppress('||') + pow_term) # 5k || 4k
- par_term.setParseAction(parallel)
- prod_term = par_term + ZeroOrMore(times_div + par_term) # 7 * 5 / 4 - 3
- prod_term.setParseAction(prod_parse_action)
- sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
- sum_term.setParseAction(sum_parse_action)
- expr << sum_term # finish the recursion
- return (expr + stringEnd).parseString(string)[0]
+ Do the parsing later, when called like `OBJ.parse_algebra()`.
+ """
+ self.case_sensitive = case_sensitive
+ self.math_expr = math_expr
+ self.tree = None
+ self.variables_used = set()
+ self.functions_used = set()
+
+ def vpa(tokens):
+ """
+ When a variable is recognized, store it in `variables_used`.
+ """
+ varname = tokens[0][0]
+ self.variables_used.add(varname)
+
+ def fpa(tokens):
+ """
+ When a function is recognized, store it in `functions_used`.
+ """
+ varname = tokens[0][0]
+ self.functions_used.add(varname)
+
+ self.variable_parse_action = vpa
+ self.function_parse_action = fpa
+
+ def parse_algebra(self):
+ """
+ Parse an algebraic expression into a tree.
+
+ Store a `pyparsing.ParseResult` in `self.tree` with proper groupings to
+ reflect parenthesis and order of operations. Leave all operators in the
+ tree and do not parse any strings of numbers into their float versions.
+
+ Adding the groups and result names makes the `repr()` of the result
+ really gross. For debugging, use something like
+ print OBJ.tree.asXML()
+ """
+ # 0.33 or 7 or .34 or 16.
+ number_part = Word(nums)
+ inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
+ # pyparsing allows spaces between tokens--`Combine` prevents that.
+ inner_number = Combine(inner_number)
+
+ # SI suffixes and percent.
+ number_suffix = MatchFirst(Literal(k) for k in SUFFIXES.keys())
+
+ # 0.33k or 17
+ plus_minus = Literal('+') | Literal('-')
+ number = Group(
+ Optional(plus_minus) +
+ inner_number +
+ Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part) +
+ Optional(number_suffix)
+ )
+ number = number("number")
+
+ # Predefine recursive variables.
+ expr = Forward()
+
+ # Handle variables passed in. They must start with letters/underscores
+ # and may contain numbers afterward.
+ inner_varname = Word(alphas + "_", alphanums + "_")
+ varname = Group(inner_varname)("variable")
+ varname.setParseAction(self.variable_parse_action)
+
+ # Same thing for functions.
+ function = Group(inner_varname + Suppress("(") + expr + Suppress(")"))("function")
+ function.setParseAction(self.function_parse_action)
+
+ atom = number | function | varname | "(" + expr + ")"
+ atom = Group(atom)("atom")
+
+ # Do the following in the correct order to preserve order of operation.
+ pow_term = atom + ZeroOrMore("^" + atom)
+ pow_term = Group(pow_term)("power")
+
+ par_term = pow_term + ZeroOrMore('||' + pow_term) # 5k || 4k
+ par_term = Group(par_term)("parallel")
+
+ prod_term = par_term + ZeroOrMore((Literal('*') | Literal('/')) + par_term) # 7 * 5 / 4
+ prod_term = Group(prod_term)("product")
+
+ sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
+ sum_term = Group(sum_term)("sum")
+
+ # Finish the recursion.
+ expr << sum_term # pylint: disable=W0104
+ self.tree = (expr + stringEnd).parseString(self.math_expr)[0]
+
+ def reduce_tree(self, handle_actions, terminal_converter=None):
+ """
+ Call `handle_actions` recursively on `self.tree` and return result.
+
+ `handle_actions` is a dictionary of node names (e.g. 'product', 'sum',
+ etc&) to functions. These functions are of the following form:
+ -input: a list of processed child nodes. If it includes any terminal
+ nodes in the list, they will be given as their processed forms also.
+ -output: whatever to be passed to the level higher, and what to
+ return for the final node.
+ `terminal_converter` is a function that takes in a token and returns a
+ processed form. The default of `None` just leaves them as strings.
+ """
+ def handle_node(node):
+ """
+ Return the result representing the node, using recursion.
+
+ Call the appropriate `handle_action` for this node. As its inputs,
+ feed it the output of `handle_node` for each child node.
+ """
+ if not isinstance(node, ParseResults):
+ # Then treat it as a terminal node.
+ if terminal_converter is None:
+ return node
+ else:
+ return terminal_converter(node)
+
+ node_name = node.getName()
+ if node_name not in handle_actions: # pragma: no cover
+ raise Exception(u"Unknown branch name '{}'".format(node_name))
+
+ action = handle_actions[node_name]
+ handled_kids = [handle_node(k) for k in node]
+ return action(handled_kids)
+
+ # Find the value of the entire tree.
+ return handle_node(self.tree)
+
+ def check_variables(self, valid_variables, valid_functions):
+ """
+ Confirm that all the variables used in the tree are valid/defined.
+
+ Otherwise, raise an UndefinedVariable containing all bad variables.
+ """
+ if self.case_sensitive:
+ casify = lambda x: x
+ else:
+ casify = lambda x: x.lower() # Lowercase for case insens.
+
+ # Test if casify(X) is valid, but return the actual bad input (i.e. X)
+ bad_vars = set(var for var in self.variables_used
+ if casify(var) not in valid_variables)
+ bad_vars.update(func for func in self.functions_used
+ if casify(func) not in valid_functions)
+
+ if bad_vars:
+ raise UndefinedVariable(' '.join(sorted(bad_vars)))
diff --git a/common/lib/calc/preview.py b/common/lib/calc/preview.py
new file mode 100644
index 0000000000..b800b7604b
--- /dev/null
+++ b/common/lib/calc/preview.py
@@ -0,0 +1,390 @@
+"""
+Provide a `latex_preview` method similar in syntax to `evaluator`.
+
+That is, given a math string, parse it and render each branch of the result,
+always returning valid latex.
+
+Because intermediate values of the render contain more data than simply the
+string of latex, store it in a custom class `LatexRendered`.
+"""
+
+from calc import ParseAugmenter, DEFAULT_VARIABLES, DEFAULT_FUNCTIONS, SUFFIXES
+
+
+class LatexRendered(object):
+ """
+ Data structure to hold a typeset representation of some math.
+
+ Fields:
+ -`latex` is a generated, valid latex string (as if it were standalone).
+ -`sans_parens` is usually the same as `latex` except without the outermost
+ parens (if applicable).
+ -`tall` is a boolean representing if the latex has any elements extending
+ above or below a normal height, specifically things of the form 'a^b' and
+ '\frac{a}{b}'. This affects the height of wrapping parenthesis.
+ """
+ def __init__(self, latex, parens=None, tall=False):
+ """
+ Instantiate with the latex representing the math.
+
+ Optionally include parenthesis to wrap around it and the height.
+ `parens` must be one of '(', '[' or '{'.
+ `tall` is a boolean (see note above).
+ """
+ self.latex = latex
+ self.sans_parens = latex
+ self.tall = tall
+
+ # Generate parens and overwrite `self.latex`.
+ if parens is not None:
+ left_parens = parens
+ if left_parens == '{':
+ left_parens = r'\{'
+
+ pairs = {'(': ')',
+ '[': ']',
+ r'\{': r'\}'}
+ if left_parens not in pairs:
+ raise Exception(
+ u"Unknown parenthesis '{}': coder error".format(left_parens)
+ )
+ right_parens = pairs[left_parens]
+
+ if self.tall:
+ left_parens = r"\left" + left_parens
+ right_parens = r"\right" + right_parens
+
+ self.latex = u"{left}{expr}{right}".format(
+ left=left_parens,
+ expr=latex,
+ right=right_parens
+ )
+
+ def __repr__(self): # pragma: no cover
+ """
+ Give a sensible representation of the object.
+
+ If `sans_parens` is different, include both.
+ If `tall` then have '<[]>' around the code, otherwise '<>'.
+ """
+ if self.latex == self.sans_parens:
+ latex_repr = u'"{}"'.format(self.latex)
+ else:
+ latex_repr = u'"{}" or "{}"'.format(self.latex, self.sans_parens)
+
+ if self.tall:
+ wrap = u'<[{}]>'
+ else:
+ wrap = u'<{}>'
+
+ return wrap.format(latex_repr)
+
+
+def render_number(children):
+ """
+ Combine the elements forming the number, escaping the suffix if needed.
+ """
+ children_latex = [k.latex for k in children]
+
+ suffix = ""
+ if children_latex[-1] in SUFFIXES:
+ suffix = children_latex.pop()
+ suffix = ur"\text{{{s}}}".format(s=suffix)
+
+ # Exponential notation-- the "E" splits the mantissa and exponent
+ if "E" in children_latex:
+ pos = children_latex.index("E")
+ mantissa = "".join(children_latex[:pos])
+ exponent = "".join(children_latex[pos + 1:])
+ latex = ur"{m}\!\times\!10^{{{e}}}{s}".format(
+ m=mantissa, e=exponent, s=suffix
+ )
+ return LatexRendered(latex, tall=True)
+ else:
+ easy_number = "".join(children_latex)
+ return LatexRendered(easy_number + suffix)
+
+
+def enrich_varname(varname):
+ """
+ Prepend a backslash if we're given a greek character.
+ """
+ greek = ("alpha beta gamma delta epsilon varepsilon zeta eta theta "
+ "vartheta iota kappa lambda mu nu xi pi rho sigma tau upsilon "
+ "phi varphi chi psi omega").split()
+
+ if varname in greek:
+ return ur"\{letter}".format(letter=varname)
+ else:
+ return varname.replace("_", r"\_")
+
+
+def variable_closure(variables, casify):
+ """
+ Wrap `render_variable` so it knows the variables allowed.
+ """
+ def render_variable(children):
+ """
+ Replace greek letters, otherwise escape the variable names.
+ """
+ varname = children[0].latex
+ if casify(varname) not in variables:
+ pass # TODO turn unknown variable red or give some kind of error
+
+ first, _, second = varname.partition("_")
+
+ if second:
+ # Then 'a_b' must become 'a_{b}'
+ varname = ur"{a}_{{{b}}}".format(
+ a=enrich_varname(first),
+ b=enrich_varname(second)
+ )
+ else:
+ varname = enrich_varname(varname)
+
+ return LatexRendered(varname) # .replace("_", r"\_"))
+ return render_variable
+
+
+def function_closure(functions, casify):
+ """
+ Wrap `render_function` so it knows the functions allowed.
+ """
+ def render_function(children):
+ """
+ Escape function names and give proper formatting to exceptions.
+
+ The exceptions being 'sqrt', 'log2', and 'log10' as of now.
+ """
+ fname = children[0].latex
+ if casify(fname) not in functions:
+ pass # TODO turn unknown function red or give some kind of error
+
+ # Wrap the input of the function with parens or braces.
+ inner = children[1].latex
+ if fname == "sqrt":
+ inner = u"{{{expr}}}".format(expr=inner)
+ else:
+ if children[1].tall:
+ inner = ur"\left({expr}\right)".format(expr=inner)
+ else:
+ inner = u"({expr})".format(expr=inner)
+
+ # Correctly format the name of the function.
+ if fname == "sqrt":
+ fname = ur"\sqrt"
+ elif fname == "log10":
+ fname = ur"\log_{10}"
+ elif fname == "log2":
+ fname = ur"\log_2"
+ else:
+ fname = ur"\text{{{fname}}}".format(fname=fname)
+
+ # Put it together.
+ latex = fname + inner
+ return LatexRendered(latex, tall=children[1].tall)
+ # Return the function within the closure.
+ return render_function
+
+
+def render_power(children):
+ """
+ Combine powers so that the latex is wrapped in curly braces correctly.
+
+ Also, if you have 'a^(b+c)' don't include that last set of parens:
+ 'a^{b+c}' is correct, whereas 'a^{(b+c)}' is extraneous.
+ """
+ if len(children) == 1:
+ return children[0]
+
+ children_latex = [k.latex for k in children if k.latex != "^"]
+ children_latex[-1] = children[-1].sans_parens
+
+ raise_power = lambda x, y: u"{}^{{{}}}".format(y, x)
+ latex = reduce(raise_power, reversed(children_latex))
+ return LatexRendered(latex, tall=True)
+
+
+def render_parallel(children):
+ """
+ Simply join the child nodes with a double vertical line.
+ """
+ if len(children) == 1:
+ return children[0]
+
+ children_latex = [k.latex for k in children if k.latex != "||"]
+ latex = r"\|".join(children_latex)
+ tall = any(k.tall for k in children)
+ return LatexRendered(latex, tall=tall)
+
+
+def render_frac(numerator, denominator):
+ r"""
+ Given a list of elements in the numerator and denominator, return a '\frac'
+
+ Avoid parens if they are unnecessary (i.e. the only thing in that part).
+ """
+ if len(numerator) == 1:
+ num_latex = numerator[0].sans_parens
+ else:
+ num_latex = r"\cdot ".join(k.latex for k in numerator)
+
+ if len(denominator) == 1:
+ den_latex = denominator[0].sans_parens
+ else:
+ den_latex = r"\cdot ".join(k.latex for k in denominator)
+
+ latex = ur"\frac{{{num}}}{{{den}}}".format(num=num_latex, den=den_latex)
+ return latex
+
+
+def render_product(children):
+ r"""
+ Format products and division nicely.
+
+ Group bunches of adjacent, equal operators. Every time it switches from
+ denominator to the next numerator, call `render_frac`. Join these groupings
+ together with '\cdot's, ending on a numerator if needed.
+
+ Examples: (`children` is formed indirectly by the string on the left)
+ 'a*b' -> 'a\cdot b'
+ 'a/b' -> '\frac{a}{b}'
+ 'a*b/c/d' -> '\frac{a\cdot b}{c\cdot d}'
+ 'a/b*c/d*e' -> '\frac{a}{b}\cdot \frac{c}{d}\cdot e'
+ """
+ if len(children) == 1:
+ return children[0]
+
+ position = "numerator" # or denominator
+ fraction_mode_ever = False
+ numerator = []
+ denominator = []
+ latex = ""
+
+ for kid in children:
+ if position == "numerator":
+ if kid.latex == "*":
+ pass # Don't explicitly add the '\cdot' yet.
+ elif kid.latex == "/":
+ # Switch to denominator mode.
+ fraction_mode_ever = True
+ position = "denominator"
+ else:
+ numerator.append(kid)
+ else:
+ if kid.latex == "*":
+ # Switch back to numerator mode.
+ # First, render the current fraction and add it to the latex.
+ latex += render_frac(numerator, denominator) + r"\cdot "
+
+ # Reset back to beginning state
+ position = "numerator"
+ numerator = []
+ denominator = []
+ elif kid.latex == "/":
+ pass # Don't explicitly add a '\frac' yet.
+ else:
+ denominator.append(kid)
+
+ # Add the fraction/numerator that we ended on.
+ if position == "denominator":
+ latex += render_frac(numerator, denominator)
+ else:
+ # We ended on a numerator--act like normal multiplication.
+ num_latex = r"\cdot ".join(k.latex for k in numerator)
+ latex += num_latex
+
+ tall = fraction_mode_ever or any(k.tall for k in children)
+ return LatexRendered(latex, tall=tall)
+
+
+def render_sum(children):
+ """
+ Concatenate elements, including the operators.
+ """
+ if len(children) == 1:
+ return children[0]
+
+ children_latex = [k.latex for k in children]
+ latex = "".join(children_latex)
+ tall = any(k.tall for k in children)
+ return LatexRendered(latex, tall=tall)
+
+
+def render_atom(children):
+ """
+ Properly handle parens, otherwise this is trivial.
+ """
+ if len(children) == 3:
+ return LatexRendered(
+ children[1].latex,
+ parens=children[0].latex,
+ tall=children[1].tall
+ )
+ else:
+ return children[0]
+
+
+def add_defaults(var, fun, case_sensitive=False):
+ """
+ Create sets with both the default and user-defined variables.
+
+ Compare to calc.add_defaults
+ """
+ var_items = set(DEFAULT_VARIABLES)
+ fun_items = set(DEFAULT_FUNCTIONS)
+
+ var_items.update(var)
+ fun_items.update(fun)
+
+ if not case_sensitive:
+ var_items = set(k.lower() for k in var_items)
+ fun_items = set(k.lower() for k in fun_items)
+
+ return var_items, fun_items
+
+
+def latex_preview(math_expr, variables=(), functions=(), case_sensitive=False):
+ """
+ Convert `math_expr` into latex, guaranteeing its parse-ability.
+
+ Analagous to `evaluator`.
+ """
+ # No need to go further
+ if math_expr.strip() == "":
+ return ""
+
+ # Parse tree
+ latex_interpreter = ParseAugmenter(math_expr, case_sensitive)
+ latex_interpreter.parse_algebra()
+
+ # Get our variables together.
+ variables, functions = add_defaults(variables, functions, case_sensitive)
+
+ # Create a recursion to evaluate the tree.
+ if case_sensitive:
+ casify = lambda x: x
+ else:
+ casify = lambda x: x.lower() # Lowercase for case insens.
+
+ render_actions = {
+ 'number': render_number,
+ 'variable': variable_closure(variables, casify),
+ 'function': function_closure(functions, casify),
+ 'atom': render_atom,
+ 'power': render_power,
+ 'parallel': render_parallel,
+ 'product': render_product,
+ 'sum': render_sum
+ }
+
+ backslash = "\\"
+ wrap_escaped_strings = lambda s: LatexRendered(
+ s.replace(backslash, backslash * 2)
+ )
+
+ output = latex_interpreter.reduce_tree(
+ render_actions,
+ terminal_converter=wrap_escaped_strings
+ )
+ return output.latex
diff --git a/common/lib/calc/tests/test_calc.py b/common/lib/calc/tests/test_calc.py
index 13cd9e9471..3b8981f5c3 100644
--- a/common/lib/calc/tests/test_calc.py
+++ b/common/lib/calc/tests/test_calc.py
@@ -7,6 +7,12 @@ import numpy
import calc
from pyparsing import ParseException
+# numpy's default behavior when it evaluates a function outside its domain
+# is to raise a warning (not an exception) which is then printed to STDOUT.
+# To prevent this from polluting the output of the tests, configure numpy to
+# ignore it instead.
+# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
+numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
class EvaluatorTest(unittest.TestCase):
"""
@@ -14,7 +20,7 @@ class EvaluatorTest(unittest.TestCase):
Go through all functionalities as specifically as possible--
work from number input to functions and complex expressions
Also test custom variable substitutions (i.e.
- `evaluator({'x':3.0},{}, '3*x')`
+ `evaluator({'x':3.0}, {}, '3*x')`
gives 9.0) and more.
"""
@@ -41,37 +47,40 @@ class EvaluatorTest(unittest.TestCase):
"""
The string '.' should not evaluate to anything.
"""
- self.assertRaises(ParseException, calc.evaluator, {}, {}, '.')
- self.assertRaises(ParseException, calc.evaluator, {}, {}, '1+.')
+ with self.assertRaises(ParseException):
+ calc.evaluator({}, {}, '.')
+ with self.assertRaises(ParseException):
+ calc.evaluator({}, {}, '1+.')
def test_trailing_period(self):
"""
Test that things like '4.' will be 4 and not throw an error
"""
- try:
- self.assertEqual(4.0, calc.evaluator({}, {}, '4.'))
- except ParseException:
- self.fail("'4.' is a valid input, but threw an exception")
+ self.assertEqual(4.0, calc.evaluator({}, {}, '4.'))
def test_exponential_answer(self):
"""
Test for correct interpretation of scientific notation
"""
answer = 50
- correct_responses = ["50", "50.0", "5e1", "5e+1",
- "50e0", "50.0e0", "500e-1"]
+ correct_responses = [
+ "50", "50.0", "5e1", "5e+1",
+ "50e0", "50.0e0", "500e-1"
+ ]
incorrect_responses = ["", "3.9", "4.1", "0", "5.01e1"]
for input_str in correct_responses:
result = calc.evaluator({}, {}, input_str)
fail_msg = "Expected '{0}' to equal {1}".format(
- input_str, answer)
+ input_str, answer
+ )
self.assertEqual(answer, result, msg=fail_msg)
for input_str in incorrect_responses:
result = calc.evaluator({}, {}, input_str)
fail_msg = "Expected '{0}' to not equal {1}".format(
- input_str, answer)
+ input_str, answer
+ )
self.assertNotEqual(answer, result, msg=fail_msg)
def test_si_suffix(self):
@@ -80,17 +89,21 @@ class EvaluatorTest(unittest.TestCase):
For instance 'k' stand for 'kilo-' so '1k' should be 1,000
"""
- test_mapping = [('4.2%', 0.042), ('2.25k', 2250), ('8.3M', 8300000),
- ('9.9G', 9.9e9), ('1.2T', 1.2e12), ('7.4c', 0.074),
- ('5.4m', 0.0054), ('8.7u', 0.0000087),
- ('5.6n', 5.6e-9), ('4.2p', 4.2e-12)]
+ test_mapping = [
+ ('4.2%', 0.042), ('2.25k', 2250), ('8.3M', 8300000),
+ ('9.9G', 9.9e9), ('1.2T', 1.2e12), ('7.4c', 0.074),
+ ('5.4m', 0.0054), ('8.7u', 0.0000087),
+ ('5.6n', 5.6e-9), ('4.2p', 4.2e-12)
+ ]
for (expr, answer) in test_mapping:
tolerance = answer * 1e-6 # Make rel. tolerance, because of floats
fail_msg = "Failure in testing suffix '{0}': '{1}' was not {2}"
fail_msg = fail_msg.format(expr[-1], expr, answer)
- self.assertAlmostEqual(calc.evaluator({}, {}, expr), answer,
- delta=tolerance, msg=fail_msg)
+ self.assertAlmostEqual(
+ calc.evaluator({}, {}, expr), answer,
+ delta=tolerance, msg=fail_msg
+ )
def test_operator_sanity(self):
"""
@@ -104,19 +117,20 @@ class EvaluatorTest(unittest.TestCase):
input_str = "{0} {1} {2}".format(var1, operator, var2)
result = calc.evaluator({}, {}, input_str)
fail_msg = "Failed on operator '{0}': '{1}' was not {2}".format(
- operator, input_str, answer)
+ operator, input_str, answer
+ )
self.assertEqual(answer, result, msg=fail_msg)
def test_raises_zero_division_err(self):
"""
Ensure division by zero gives an error
"""
- self.assertRaises(ZeroDivisionError, calc.evaluator,
- {}, {}, '1/0')
- self.assertRaises(ZeroDivisionError, calc.evaluator,
- {}, {}, '1/0.0')
- self.assertRaises(ZeroDivisionError, calc.evaluator,
- {'x': 0.0}, {}, '1/x')
+ with self.assertRaises(ZeroDivisionError):
+ calc.evaluator({}, {}, '1/0')
+ with self.assertRaises(ZeroDivisionError):
+ calc.evaluator({}, {}, '1/0.0')
+ with self.assertRaises(ZeroDivisionError):
+ calc.evaluator({'x': 0.0}, {}, '1/x')
def test_parallel_resistors(self):
"""
@@ -153,7 +167,8 @@ class EvaluatorTest(unittest.TestCase):
input_str = "{0}({1})".format(fname, arg)
result = calc.evaluator({}, {}, input_str)
fail_msg = "Failed on function {0}: '{1}' was not {2}".format(
- fname, input_str, val)
+ fname, input_str, val
+ )
self.assertAlmostEqual(val, result, delta=tolerance, msg=fail_msg)
def test_trig_functions(self):
@@ -177,17 +192,16 @@ class EvaluatorTest(unittest.TestCase):
arcsin_inputs = ['-0.707', '0', '0.5', '0.588', '1.298 + 0.635*j']
arcsin_angles = [-0.785, 0, 0.524, 0.629, 1 + 1j]
self.assert_function_values('arcsin', arcsin_inputs, arcsin_angles)
- # Rather than throwing an exception, numpy.arcsin gives nan
- # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(-1.1)')))
- # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(1.1)')))
- # Disabled for now because they are giving a runtime warning... :-/
+ # Rather than a complex number, numpy.arcsin gives nan
+ self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(-1.1)')))
+ self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(1.1)')))
# Include those where the real part is between 0 and pi
arccos_inputs = ['1', '0.866', '0.809', '0.834-0.989*j']
arccos_angles = [0, 0.524, 0.628, 1 + 1j]
self.assert_function_values('arccos', arccos_inputs, arccos_angles)
- # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(-1.1)')))
- # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(1.1)')))
+ self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(-1.1)')))
+ self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(1.1)')))
# Has the same range as arcsin
arctan_inputs = ['-1', '0', '0.577', '0.727', '0.272 + 1.084*j']
@@ -303,21 +317,29 @@ class EvaluatorTest(unittest.TestCase):
"""
# Test sqrt
- self.assert_function_values('sqrt',
- [0, 1, 2, 1024], # -1
- [0, 1, 1.414, 32]) # 1j
+ self.assert_function_values(
+ 'sqrt',
+ [0, 1, 2, 1024], # -1
+ [0, 1, 1.414, 32] # 1j
+ )
# sqrt(-1) is NAN not j (!!).
# Test logs
- self.assert_function_values('log10',
- [0.1, 1, 3.162, 1000000, '1+j'],
- [-1, 0, 0.5, 6, 0.151 + 0.341j])
- self.assert_function_values('log2',
- [0.5, 1, 1.414, 1024, '1+j'],
- [-1, 0, 0.5, 10, 0.5 + 1.133j])
- self.assert_function_values('ln',
- [0.368, 1, 1.649, 2.718, 42, '1+j'],
- [-1, 0, 0.5, 1, 3.738, 0.347 + 0.785j])
+ self.assert_function_values(
+ 'log10',
+ [0.1, 1, 3.162, 1000000, '1+j'],
+ [-1, 0, 0.5, 6, 0.151 + 0.341j]
+ )
+ self.assert_function_values(
+ 'log2',
+ [0.5, 1, 1.414, 1024, '1+j'],
+ [-1, 0, 0.5, 10, 0.5 + 1.133j]
+ )
+ self.assert_function_values(
+ 'ln',
+ [0.368, 1, 1.649, 2.718, 42, '1+j'],
+ [-1, 0, 0.5, 1, 3.738, 0.347 + 0.785j]
+ )
# Test abs
self.assert_function_values('abs', [-1, 0, 1, 'j'], [1, 0, 1, 1])
@@ -341,26 +363,28 @@ class EvaluatorTest(unittest.TestCase):
"""
# Of the form ('expr', python value, tolerance (or None for exact))
- default_variables = [('j', 1j, None),
- ('e', 2.7183, 1e-3),
- ('pi', 3.1416, 1e-3),
- # c = speed of light
- ('c', 2.998e8, 1e5),
- # 0 deg C = T Kelvin
- ('T', 298.15, 0.01),
- # Note k = scipy.constants.k = 1.3806488e-23
- ('k', 1.3806488e-23, 1e-26),
- # Note q = scipy.constants.e = 1.602176565e-19
- ('q', 1.602176565e-19, 1e-22)]
+ default_variables = [
+ ('i', 1j, None),
+ ('j', 1j, None),
+ ('e', 2.7183, 1e-4),
+ ('pi', 3.1416, 1e-4),
+ ('k', 1.3806488e-23, 1e-26), # Boltzmann constant (Joules/Kelvin)
+ ('c', 2.998e8, 1e5), # Light Speed in (m/s)
+ ('T', 298.15, 0.01), # 0 deg C = T Kelvin
+ ('q', 1.602176565e-19, 1e-22) # Fund. Charge (Coulombs)
+ ]
for (variable, value, tolerance) in default_variables:
fail_msg = "Failed on constant '{0}', not within bounds".format(
- variable)
+ variable
+ )
result = calc.evaluator({}, {}, variable)
if tolerance is None:
self.assertEqual(value, result, msg=fail_msg)
else:
- self.assertAlmostEqual(value, result,
- delta=tolerance, msg=fail_msg)
+ self.assertAlmostEqual(
+ value, result,
+ delta=tolerance, msg=fail_msg
+ )
def test_complex_expression(self):
"""
@@ -370,21 +394,51 @@ class EvaluatorTest(unittest.TestCase):
self.assertAlmostEqual(
calc.evaluator({}, {}, "(2^2+1.0)/sqrt(5e0)*5-1"),
10.180,
- delta=1e-3)
-
+ delta=1e-3
+ )
self.assertAlmostEqual(
calc.evaluator({}, {}, "1+1/(1+1/(1+1/(1+1)))"),
1.6,
- delta=1e-3)
+ delta=1e-3
+ )
self.assertAlmostEqual(
calc.evaluator({}, {}, "10||sin(7+5)"),
- -0.567, delta=0.01)
- self.assertAlmostEqual(calc.evaluator({}, {}, "sin(e)"),
- 0.41, delta=0.01)
- self.assertAlmostEqual(calc.evaluator({}, {}, "k*T/q"),
- 0.025, delta=1e-3)
- self.assertAlmostEqual(calc.evaluator({}, {}, "e^(j*pi)"),
- -1, delta=1e-5)
+ -0.567, delta=0.01
+ )
+ self.assertAlmostEqual(
+ calc.evaluator({}, {}, "sin(e)"),
+ 0.41, delta=0.01
+ )
+ self.assertAlmostEqual(
+ calc.evaluator({}, {}, "k*T/q"),
+ 0.025, delta=1e-3
+ )
+ self.assertAlmostEqual(
+ calc.evaluator({}, {}, "e^(j*pi)"),
+ -1, delta=1e-5
+ )
+
+ def test_explicit_sci_notation(self):
+ """
+ Expressions like 1.6*10^-3 (not 1.6e-3) it should evaluate.
+ """
+ self.assertEqual(
+ calc.evaluator({}, {}, "-1.6*10^-3"),
+ -0.0016
+ )
+ self.assertEqual(
+ calc.evaluator({}, {}, "-1.6*10^(-3)"),
+ -0.0016
+ )
+
+ self.assertEqual(
+ calc.evaluator({}, {}, "-1.6*10^3"),
+ -1600
+ )
+ self.assertEqual(
+ calc.evaluator({}, {}, "-1.6*10^(3)"),
+ -1600
+ )
def test_simple_vars(self):
"""
@@ -404,19 +458,24 @@ class EvaluatorTest(unittest.TestCase):
self.assertEqual(calc.evaluator(variables, {}, 'loooooong'), 6.4)
# Test a simple equation
- self.assertAlmostEqual(calc.evaluator(variables, {}, '3*x-y'),
- 21.25, delta=0.01) # = 3 * 9.72 - 7.91
- self.assertAlmostEqual(calc.evaluator(variables, {}, 'x*y'),
- 76.89, delta=0.01)
+ self.assertAlmostEqual(
+ calc.evaluator(variables, {}, '3*x-y'),
+ 21.25, delta=0.01 # = 3 * 9.72 - 7.91
+ )
+ self.assertAlmostEqual(
+ calc.evaluator(variables, {}, 'x*y'),
+ 76.89, delta=0.01
+ )
self.assertEqual(calc.evaluator({'x': 9.72, 'y': 7.91}, {}, "13"), 13)
self.assertEqual(calc.evaluator(variables, {}, "13"), 13)
self.assertEqual(
- calc.evaluator({
- 'a': 2.2997471478310274, 'k': 9, 'm': 8,
- 'x': 0.66009498411213041},
- {}, "5"),
- 5)
+ calc.evaluator(
+ {'a': 2.2997471478310274, 'k': 9, 'm': 8, 'x': 0.6600949841121},
+ {}, "5"
+ ),
+ 5
+ )
def test_variable_case_sensitivity(self):
"""
@@ -424,15 +483,21 @@ class EvaluatorTest(unittest.TestCase):
"""
self.assertEqual(
calc.evaluator({'R1': 2.0, 'R3': 4.0}, {}, "r1*r3"),
- 8.0)
+ 8.0
+ )
variables = {'t': 1.0}
self.assertEqual(calc.evaluator(variables, {}, "t"), 1.0)
self.assertEqual(calc.evaluator(variables, {}, "T"), 1.0)
- self.assertEqual(calc.evaluator(variables, {}, "t", cs=True), 1.0)
+ self.assertEqual(
+ calc.evaluator(variables, {}, "t", case_sensitive=True),
+ 1.0
+ )
# Recall 'T' is a default constant, with value 298.15
- self.assertAlmostEqual(calc.evaluator(variables, {}, "T", cs=True),
- 298, delta=0.2)
+ self.assertAlmostEqual(
+ calc.evaluator(variables, {}, "T", case_sensitive=True),
+ 298, delta=0.2
+ )
def test_simple_funcs(self):
"""
@@ -445,22 +510,41 @@ class EvaluatorTest(unittest.TestCase):
self.assertEqual(calc.evaluator(variables, functions, 'id(x)'), 4.712)
functions.update({'f': numpy.sin})
- self.assertAlmostEqual(calc.evaluator(variables, functions, 'f(x)'),
- -1, delta=1e-3)
+ self.assertAlmostEqual(
+ calc.evaluator(variables, functions, 'f(x)'),
+ -1, delta=1e-3
+ )
- def test_function_case_sensitivity(self):
+ def test_function_case_insensitive(self):
"""
- Test the case sensitivity of functions
+ Test case insensitive evaluation
+
+ Normal functions with some capitals should be fine
"""
- functions = {'f': lambda x: x,
- 'F': lambda x: x + 1}
- # Test case insensitive evaluation
- # Both evaulations should call the same function
- self.assertEqual(calc.evaluator({}, functions, 'f(6)'),
- calc.evaluator({}, functions, 'F(6)'))
- # Test case sensitive evaluation
- self.assertNotEqual(calc.evaluator({}, functions, 'f(6)', cs=True),
- calc.evaluator({}, functions, 'F(6)', cs=True))
+ self.assertAlmostEqual(
+ -0.28,
+ calc.evaluator({}, {}, 'SiN(6)', case_sensitive=False),
+ delta=1e-3
+ )
+
+ def test_function_case_sensitive(self):
+ """
+ Test case sensitive evaluation
+
+ Incorrectly capitilized should fail
+ Also, it should pick the correct version of a function.
+ """
+ with self.assertRaisesRegexp(calc.UndefinedVariable, 'SiN'):
+ calc.evaluator({}, {}, 'SiN(6)', case_sensitive=True)
+
+ # With case sensitive turned on, it should pick the right function
+ functions = {'f': lambda x: x, 'F': lambda x: x + 1}
+ self.assertEqual(
+ 6, calc.evaluator({}, functions, 'f(6)', case_sensitive=True)
+ )
+ self.assertEqual(
+ 7, calc.evaluator({}, functions, 'F(6)', case_sensitive=True)
+ )
def test_undefined_vars(self):
"""
@@ -468,9 +552,9 @@ class EvaluatorTest(unittest.TestCase):
"""
variables = {'R1': 2.0, 'R3': 4.0}
- self.assertRaises(calc.UndefinedVariable, calc.evaluator,
- {}, {}, "5+7 QWSEKO")
- self.assertRaises(calc.UndefinedVariable, calc.evaluator,
- {'r1': 5}, {}, "r1+r2")
- self.assertRaises(calc.UndefinedVariable, calc.evaluator,
- variables, {}, "r1*r3", cs=True)
+ with self.assertRaisesRegexp(calc.UndefinedVariable, 'QWSEKO'):
+ calc.evaluator({}, {}, "5+7*QWSEKO")
+ with self.assertRaisesRegexp(calc.UndefinedVariable, 'r2'):
+ calc.evaluator({'r1': 5}, {}, "r1+r2")
+ with self.assertRaisesRegexp(calc.UndefinedVariable, 'r1 r3'):
+ calc.evaluator(variables, {}, "r1*r3", case_sensitive=True)
diff --git a/common/lib/calc/tests/test_preview.py b/common/lib/calc/tests/test_preview.py
new file mode 100644
index 0000000000..0008cdda47
--- /dev/null
+++ b/common/lib/calc/tests/test_preview.py
@@ -0,0 +1,251 @@
+# -*- coding: utf-8 -*-
+"""
+Unit tests for preview.py
+"""
+
+import unittest
+import preview
+import pyparsing
+
+
+class LatexRenderedTest(unittest.TestCase):
+ """
+ Test the initializing code for LatexRendered.
+
+ Specifically that it stores the correct data and handles parens well.
+ """
+ def test_simple(self):
+ """
+ Test that the data values are stored without changing.
+ """
+ math = 'x^2'
+ obj = preview.LatexRendered(math, tall=True)
+ self.assertEquals(obj.latex, math)
+ self.assertEquals(obj.sans_parens, math)
+ self.assertEquals(obj.tall, True)
+
+ def _each_parens(self, with_parens, math, parens, tall=False):
+ """
+ Helper method to test the way parens are wrapped.
+ """
+ obj = preview.LatexRendered(math, parens=parens, tall=tall)
+ self.assertEquals(obj.latex, with_parens)
+ self.assertEquals(obj.sans_parens, math)
+ self.assertEquals(obj.tall, tall)
+
+ def test_parens(self):
+ """ Test curvy parens. """
+ self._each_parens('(x+y)', 'x+y', '(')
+
+ def test_brackets(self):
+ """ Test brackets. """
+ self._each_parens('[x+y]', 'x+y', '[')
+
+ def test_squiggles(self):
+ """ Test curly braces. """
+ self._each_parens(r'\{x+y\}', 'x+y', '{')
+
+ def test_parens_tall(self):
+ """ Test curvy parens with the tall parameter. """
+ self._each_parens(r'\left(x^y\right)', 'x^y', '(', tall=True)
+
+ def test_brackets_tall(self):
+ """ Test brackets, also tall. """
+ self._each_parens(r'\left[x^y\right]', 'x^y', '[', tall=True)
+
+ def test_squiggles_tall(self):
+ """ Test tall curly braces. """
+ self._each_parens(r'\left\{x^y\right\}', 'x^y', '{', tall=True)
+
+ def test_bad_parens(self):
+ """ Check that we get an error with invalid parens. """
+ with self.assertRaisesRegexp(Exception, 'Unknown parenthesis'):
+ preview.LatexRendered('x^2', parens='not parens')
+
+
+class LatexPreviewTest(unittest.TestCase):
+ """
+ Run integrative tests for `latex_preview`.
+
+ All functionality was tested `RenderMethodsTest`, but see if it combines
+ all together correctly.
+ """
+ def test_no_input(self):
+ """
+ With no input (including just whitespace), see that no error is thrown.
+ """
+ self.assertEquals('', preview.latex_preview(''))
+ self.assertEquals('', preview.latex_preview(' '))
+ self.assertEquals('', preview.latex_preview(' \t '))
+
+ def test_number_simple(self):
+ """ Simple numbers should pass through. """
+ self.assertEquals(preview.latex_preview('3.1415'), '3.1415')
+
+ def test_number_suffix(self):
+ """ Suffixes should be escaped. """
+ self.assertEquals(preview.latex_preview('1.618k'), r'1.618\text{k}')
+
+ def test_number_sci_notation(self):
+ """ Numbers with scientific notation should display nicely """
+ self.assertEquals(
+ preview.latex_preview('6.0221413E+23'),
+ r'6.0221413\!\times\!10^{+23}'
+ )
+ self.assertEquals(
+ preview.latex_preview('-6.0221413E+23'),
+ r'-6.0221413\!\times\!10^{+23}'
+ )
+
+ def test_number_sci_notation_suffix(self):
+ """ Test numbers with both of these. """
+ self.assertEquals(
+ preview.latex_preview('6.0221413E+23k'),
+ r'6.0221413\!\times\!10^{+23}\text{k}'
+ )
+ self.assertEquals(
+ preview.latex_preview('-6.0221413E+23k'),
+ r'-6.0221413\!\times\!10^{+23}\text{k}'
+ )
+
+ def test_variable_simple(self):
+ """ Simple valid variables should pass through. """
+ self.assertEquals(preview.latex_preview('x', variables=['x']), 'x')
+
+ def test_greek(self):
+ """ Variable names that are greek should be formatted accordingly. """
+ self.assertEquals(preview.latex_preview('pi'), r'\pi')
+
+ def test_variable_subscript(self):
+ """ Things like 'epsilon_max' should display nicely """
+ self.assertEquals(
+ preview.latex_preview('epsilon_max', variables=['epsilon_max']),
+ r'\epsilon_{max}'
+ )
+
+ def test_function_simple(self):
+ """ Valid function names should be escaped. """
+ self.assertEquals(
+ preview.latex_preview('f(3)', functions=['f']),
+ r'\text{f}(3)'
+ )
+
+ def test_function_tall(self):
+ r""" Functions surrounding a tall element should have \left, \right """
+ self.assertEquals(
+ preview.latex_preview('f(3^2)', functions=['f']),
+ r'\text{f}\left(3^{2}\right)'
+ )
+
+ def test_function_sqrt(self):
+ """ Sqrt function should be handled specially. """
+ self.assertEquals(preview.latex_preview('sqrt(3)'), r'\sqrt{3}')
+
+ def test_function_log10(self):
+ """ log10 function should be handled specially. """
+ self.assertEquals(preview.latex_preview('log10(3)'), r'\log_{10}(3)')
+
+ def test_function_log2(self):
+ """ log2 function should be handled specially. """
+ self.assertEquals(preview.latex_preview('log2(3)'), r'\log_2(3)')
+
+ def test_power_simple(self):
+ """ Powers should wrap the elements with braces correctly. """
+ self.assertEquals(preview.latex_preview('2^3^4'), '2^{3^{4}}')
+
+ def test_power_parens(self):
+ """ Powers should ignore the parenthesis of the last math. """
+ self.assertEquals(preview.latex_preview('2^3^(4+5)'), '2^{3^{4+5}}')
+
+ def test_parallel(self):
+ r""" Parallel items should combine with '\|'. """
+ self.assertEquals(preview.latex_preview('2||3'), r'2\|3')
+
+ def test_product_mult_only(self):
+ r""" Simple products should combine with a '\cdot'. """
+ self.assertEquals(preview.latex_preview('2*3'), r'2\cdot 3')
+
+ def test_product_big_frac(self):
+ """ Division should combine with '\frac'. """
+ self.assertEquals(
+ preview.latex_preview('2*3/4/5'),
+ r'\frac{2\cdot 3}{4\cdot 5}'
+ )
+
+ def test_product_single_frac(self):
+ """ Division should ignore parens if they are extraneous. """
+ self.assertEquals(
+ preview.latex_preview('(2+3)/(4+5)'),
+ r'\frac{2+3}{4+5}'
+ )
+
+ def test_product_keep_going(self):
+ """
+ Complex products/quotients should split into many '\frac's when needed.
+ """
+ self.assertEquals(
+ preview.latex_preview('2/3*4/5*6'),
+ r'\frac{2}{3}\cdot \frac{4}{5}\cdot 6'
+ )
+
+ def test_sum(self):
+ """ Sums should combine its elements. """
+ # Use 'x' as the first term (instead of, say, '1'), so it can't be
+ # interpreted as a negative number.
+ self.assertEquals(
+ preview.latex_preview('-x+2-3+4', variables=['x']),
+ '-x+2-3+4'
+ )
+
+ def test_sum_tall(self):
+ """ A complicated expression should not hide the tallness. """
+ self.assertEquals(
+ preview.latex_preview('(2+3^2)'),
+ r'\left(2+3^{2}\right)'
+ )
+
+ def test_complicated(self):
+ """
+ Given complicated input, ensure that exactly the correct string is made.
+ """
+ self.assertEquals(
+ preview.latex_preview('11*f(x)+x^2*(3||4)/sqrt(pi)'),
+ r'11\cdot \text{f}(x)+\frac{x^{2}\cdot (3\|4)}{\sqrt{\pi}}'
+ )
+
+ self.assertEquals(
+ preview.latex_preview('log10(1+3/4/Cos(x^2)*(x+1))',
+ case_sensitive=True),
+ (r'\log_{10}\left(1+\frac{3}{4\cdot \text{Cos}\left(x^{2}\right)}'
+ r'\cdot (x+1)\right)')
+ )
+
+ def test_syntax_errors(self):
+ """
+ Test a lot of math strings that give syntax errors
+
+ Rather than have a lot of self.assertRaises, make a loop and keep track
+ of those that do not throw a `ParseException`, and assert at the end.
+ """
+ bad_math_list = [
+ '11+',
+ '11*',
+ 'f((x)',
+ 'sqrt(x^)',
+ '3f(x)', # Not 3*f(x)
+ '3|4',
+ '3|||4'
+ ]
+ bad_exceptions = {}
+ for math in bad_math_list:
+ try:
+ preview.latex_preview(math)
+ except pyparsing.ParseException:
+ pass # This is what we were expecting. (not excepting :P)
+ except Exception as error: # pragma: no cover
+ bad_exceptions[math] = error
+ else: # pragma: no cover
+ # If there is no exception thrown, this is a problem
+ bad_exceptions[math] = None
+
+ self.assertEquals({}, bad_exceptions)
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index 29800a211b..9defd2c5e6 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -16,6 +16,8 @@ Module containing the problem elements which render into input objects
- crystallography
- vsepr_input
- drag_and_drop
+- formulaequationinput
+- chemicalequationinput
These are matched by *.html files templates/*.html which are mako templates with the
actual html.
@@ -47,6 +49,7 @@ import pyparsing
from .registry import TagRegistry
from chem import chemcalc
+from preview import latex_preview
import xqueue_interface
from datetime import datetime
@@ -531,7 +534,7 @@ class TextLine(InputTypeBase):
is used e.g. for embedding simulations turned into questions.
Example:
-
+
This example will render out a text line with a math preview and the text 'm/s'
after the end of the text line.
@@ -1037,15 +1040,16 @@ class ChemicalEquationInput(InputTypeBase):
result = {'preview': '',
'error': ''}
- formula = data['formula']
- if formula is None:
+ try:
+ formula = data['formula']
+ except KeyError:
result['error'] = "No formula specified."
return result
try:
result['preview'] = chemcalc.render_to_html(formula)
except pyparsing.ParseException as p:
- result['error'] = "Couldn't parse formula: {0}".format(p)
+ result['error'] = u"Couldn't parse formula: {0}".format(p.msg)
except Exception:
# this is unexpected, so log
log.warning(
@@ -1056,6 +1060,98 @@ class ChemicalEquationInput(InputTypeBase):
registry.register(ChemicalEquationInput)
+#-------------------------------------------------------------------------
+
+
+class FormulaEquationInput(InputTypeBase):
+ """
+ An input type for entering formula equations. Supports live preview.
+
+ Example:
+
+
+
+ options: size -- width of the textbox.
+ """
+
+ template = "formulaequationinput.html"
+ tags = ['formulaequationinput']
+
+ @classmethod
+ def get_attributes(cls):
+ """
+ Can set size of text field.
+ """
+ return [Attribute('size', '20'), ]
+
+ def _extra_context(self):
+ """
+ TODO (vshnayder): Get rid of 'previewer' once we have a standard way of requiring js to be loaded.
+ """
+ # `reported_status` is basically `status`, except we say 'unanswered'
+ reported_status = ''
+ if self.status == 'unsubmitted':
+ reported_status = 'unanswered'
+ elif self.status in ('correct', 'incorrect', 'incomplete'):
+ reported_status = self.status
+
+ return {
+ 'previewer': '/static/js/capa/src/formula_equation_preview.js',
+ 'reported_status': reported_status
+ }
+
+ def handle_ajax(self, dispatch, get):
+ '''
+ Since we only have formcalc preview this input, check to see if it
+ matches the corresponding dispatch and send it through if it does
+ '''
+ if dispatch == 'preview_formcalc':
+ return self.preview_formcalc(get)
+ return {}
+
+ def preview_formcalc(self, get):
+ """
+ Render an preview of a formula or equation. `get` should
+ contain a key 'formula' with a math expression.
+
+ Returns a json dictionary:
+ {
+ 'preview' : '' or ''
+ 'error' : 'the-error' or ''
+ 'request_start' :