Commit bc25defd by Julian Arni

Merge branch 'master' into feature/jkarni/jsinput

Conflicts:
	doc/public/index.rst
parents 49fba9e8 2fadd655
......@@ -10,6 +10,8 @@
.AppleDouble
database.sqlite
requirements/private.txt
lms/envs/private.py
cms/envs/private.py
courseware/static/js/mathjax/*
flushdb.sh
build
......@@ -27,6 +29,7 @@ conf/locale/en/LC_MESSAGES/*.po
!messages.po
lms/static/sass/*.css
lms/static/sass/application.scss
lms/static/sass/course.scss
cms/static/sass/*.css
lms/lib/comment_client/python
nosetests.xml
......
......@@ -72,3 +72,9 @@ Giulio Gratta <giulio@giuliogratta.com>
David Baumgold <david@davidbaumgold.com>
Jason Bau <jbau@stanford.edu>
Frances Botsford <frances@edx.org>
Jonah Stanley <Jonah_Stanley@brown.edu>
Slater Victoroff <slater.r.victoroff@gmail.com>
Peter Fogg <peter.p.fogg@gmail.com>
Bethany LaPenta <lapentab@mit.edu>
Renzo Lucioni <renzolucioni@gmail.com>
Felix Sun <felixsun@mit.edu>
Change Log
----------
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: Remove XML from the video component editor. All settings are
moved to be edited as metadata.
XModule: Only write out assets files if the contents have changed.
XModule: Don't delete generated xmodule asset files when compiling (for
instance, when XModule provides a coffeescript file, don't delete
the associated javascript)
Studio: For courses running on edx.org (marketing site), disable fields in
Course Settings that do not apply.
Common: Make asset watchers run as singletons (so they won't start if the
watcher is already running in another shell).
Common: Use coffee directly when watching for coffeescript file changes.
Common: Make rake provide better error messages if packages are missing.
Common: Repairs development documentation generation by sphinx.
LMS: Problem rescoring. Added options on the Grades tab of the
Instructor Dashboard to allow all students' submissions for a
particular problem to be rescored. Also supports resetting all
students' number of attempts to zero. Provides a list of background
tasks that are currently running for the course, and an option to
see a history of background tasks for a given problem.
LMS: Fixed the preferences scope for storing data in xmodules.
LMS: Forums. Added handling for case where discussion module can get `None` as
value of lms.start in `lms/djangoapps/django_comment_client/utils.py`
Studio, LMS: Make ModelTypes more strict about their expected content (for
instance, Boolean, Integer, String), but also allow them to hold either the
typed value, or a String that can be converted to their typed value. For example,
an Integer can contain 3 or '3'. This changed an update to the xblock library.
LMS: Courses whose id matches a regex in the COURSES_WITH_UNSAFE_CODE Django
setting now run entirely outside the Python sandbox.
Blades: Added tests for Video Alpha player.
Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox.
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
captions.
CMS: Allow editors to delete uploaded files/assets
XModules: `XModuleDescriptor.__init__` and `XModule.__init__` dropped the
`location` parameter (and added it as a field), and renamed `system` to `runtime`,
to accord more closely to `XBlock.__init__`
LMS: Some errors handling Non-ASCII data in XML courses have been fixed.
LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and
SEGMENT_IO_LMS feature flag is on)
Blades: Simplify calc.py (which is used for the Numerical/Formula responses); add trig/other functions.
LMS: Background colors on login, register, and courseware have been corrected
back to white.
LMS: Accessibility improvements have been made to several courseware and
navigation elements.
LMS: Small design/presentation changes to login and register views.
LMS: Functionality added to instructor enrollment tab in LMS such that invited
student can be auto-enrolled in course or when activating if not current
student.
Blades: Staff debug info is now accessible for Graphical Slider Tool problems.
Blades: For Video Alpha the events ready, play, pause, seek, and speed change
are logged on the server (in the logs).
Common: all dates and times are not time zone aware datetimes. No code should create or use struct_times nor naive
datetimes.
Common: Developers can now have private Django settings files.
Common: Safety code added to prevent anything above the vertical level in the
course tree from being marked as version='draft'. It will raise an exception if
the code tries to so mark a node. We need the backtraces to figure out where
this very infrequent intermittent marking was occurring. It was making courses
look different in Studio than in LMS.
Deploy: MKTG_URLS is now read from env.json.
Common: Theming makes it possible to change the look of the site, from
Stanford.
Common: Accessibility UI fixes.
Common: The "duplicate email" error message is more informative.
Studio: Component metadata settings editor.
Studio: Autoplay for Video Alpha is disabled (only in Studio).
Studio: Single-click creation for video and discussion components.
Studio: fixed a bad link in the activation page.
LMS: Changed the help button text.
LMS: Fixed failing numeric response (decimal but no trailing digits).
LMS: XML Error module no longer shows students a stack trace.
Blades: Videoalpha.
XModules: Added partial credit for foldit module.
XModules: Added "randomize" XModule to list of XModule types.
XModules: Show errors with full descriptors.
XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is
dropped suddenly.
XQueue: Upload file submissions to a specially named bucket in S3.
Common: Removed request debugger.
Common: Updated Django to version 1.4.5.
Common: Updated CodeJail.
Common: Allow setting of authentication session cookie name.
......@@ -4,3 +4,4 @@ gem 'sass', '3.1.15'
gem 'bourbon', '~> 1.3.6'
gem 'colorize', '~> 0.5.8'
gem 'launchy', '~> 2.1.2'
gem 'sys-proctable', '~> 0.9.3'
This is edX, a platform for online course delivery. The project is primarily
written in [Python](http://python.org/), using the
[Django](https://www.djangoproject.com/) framework. We also use some
[Ruby](http://www.ruby-lang.org/) and some [NodeJS](http://nodejs.org/).
This is the main edX platform which consists of LMS and Studio.
See [code.edx.org](http://code.edx.org/) for other parts of the edX code base.
Installation
============
The installation process is a bit messy at the moment. Here's a high-level
overview of what you should do to get started.
**TLDR:** There is a `scripts/create-dev-env.sh` script that will attempt to set all
of this up for you. If you're in a hurry, run that script. Otherwise, I suggest
that you understand what the script is doing, and why, by reading this document.
There is a `scripts/create-dev-env.sh` that will attempt to set up a development
environment.
If you want to better understand what the script is doing, keep reading.
Directory Hierarchy
-------------------
This code assumes that it is checked out in a directory that has three sibling
directories: `data` (used for XML course data), `db` (used to hold a
[sqlite](https://sqlite.org/) database), and `log` (used to hold logs). If you
......@@ -77,6 +76,7 @@ environment), and Node has a library installer called
Once you've got your languages and virtual environments set up, install
the libraries like so:
$ pip install -r requirements/edx/pre.txt
$ pip install -r requirements/edx/base.txt
$ pip install -r requirements/edx/post.txt
$ bundle install
......@@ -111,11 +111,11 @@ CMS templates. Fortunately, `rake` will do all of this for you! Just run:
$ rake django-admin[syncdb]
$ rake django-admin[migrate]
$ rake django-admin[update_templates]
$ rake cms:update_templates
If you are running these commands using the [`zsh`](http://www.zsh.org/) shell,
zsh will assume that you are doing
[shell globbing](https://en.wikipedia.org/wiki/Glob_(programming)), search for
[shell globbing](https://en.wikipedia.org/wiki/Glob_%28programming%29), search for
a file in your directory named `django-adminsyncdb` or `django-adminmigrate`,
and fail. To fix this, just surround the argument with quotation marks, so that
you're running `rake "django-admin[syncdb]"`.
......@@ -144,10 +144,28 @@ in the `data` directory, instead of in Mongo. To run this older version, run:
$ rake lms
Further Documentation
=====================
Once you've got your project up and running, you can check out the `docs`
directory to see more documentation about how edX is structured.
License
-------
The code in this repository is licensed under version 3 of the AGPL unless
otherwise noted.
Please see ``LICENSE.txt`` for details.
How to Contribute
-----------------
Contributions are very welcome. The easiest way is to fork this repo, and then
make a pull request from your fork. The first time you make a pull request, you
may be asked to sign a Contributor Agreement.
Reporting Security Issues
-------------------------
Please do not report security issues in public. Please email security@edx.org
Mailing List and IRC Channel
----------------------------
You can discuss this code on the [edx-code Google Group](https://groups.google.com/forum/#!forum/edx-code) or in the
`edx-code` IRC channel on Freenode.
Instructions
============
For each pull request, add one or more lines to the bottom of the change list. When
code is released to production, change the `Upcoming` entry to todays date, and add
a new block at the bottom of the file.
Upcoming
--------
Change log entries should be targeted at end users. A good place to start is the
user story that instigated the pull request.
Changes
=======
Upcoming
--------
* Fix: Deleting last component in a unit does not work
* Fix: Unit name is editable when a unit is public
* Fix: Visual feedback inconsistent when saving a unit name change
......@@ -39,8 +39,6 @@ def get_users_in_course_group_by_role(location, role):
'''
Create all permission groups for a new course and subscribe the caller into those roles
'''
def create_all_course_groups(creator, location):
create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME)
create_new_course_group(creator, location, STAFF_ROLE_NAME)
......@@ -57,13 +55,11 @@ def create_new_course_group(creator, location, role):
return
'''
This is to be called only by either a command line code path or through a app which has already
asserted permissions
'''
def _delete_course_group(location):
'''
This is to be called only by either a command line code path or through a app which has already
asserted permissions
'''
# remove all memberships
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all():
......@@ -75,13 +71,11 @@ def _delete_course_group(location):
user.groups.remove(staff)
user.save()
'''
This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action
'''
def _copy_course_group(source, dest):
'''
This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action
'''
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME))
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all():
......
......@@ -11,8 +11,6 @@ Feature: Advanced (manual) course policy
Given I am on the Advanced Course Settings page in Studio
Then the settings are alphabetized
# Skipped because Ubuntu ChromeDriver cannot click notification "Cancel"
@skip
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
......@@ -21,8 +19,6 @@ Feature: Advanced (manual) course policy
And I reload the page
Then the policy key value is unchanged
# Skipped because Ubuntu ChromeDriver cannot click notification "Save"
@skip
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
......@@ -30,17 +26,20 @@ Feature: Advanced (manual) course policy
And I reload the page
Then the policy key value is changed
# Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input
@skip
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
When I create a JSON object as a value for "discussion_topics"
Then it is displayed as formatted
And I reload the page
Then it is displayed as formatted
# Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input
@skip
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"
Then I get an error on save
And I reload the page
Then the policy key value is unchanged
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
......
......@@ -2,13 +2,8 @@
#pylint: disable=W0621
from lettuce import world, step
from common import *
from nose.tools import assert_false, assert_equal
"""
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
"""
from selenium.webdriver.common.keys import Keys
from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true
from common import type_in_codemirror
KEY_CSS = '.key input.policy-key'
VALUE_CSS = 'textarea.json'
......@@ -33,17 +28,20 @@ def i_am_on_advanced_course_settings(step):
@step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name):
css = 'a.%s-button' % name.lower()
world.css_click(css)
# Save was clicked if either the save notification bar is gone, or we have a error notification
# overlaying it (expected in the case of typing Object into display_name).
def save_clicked():
confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning')
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
return confirmation_dismissed or error_showing
assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.')
@step(u'I edit the value of a policy key$')
def edit_the_value_of_a_policy_key(step):
"""
It is hard to figure out how to get into the CodeMirror
area, so cheat and do it from the policy key field :)
"""
e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X')
type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X')
@step(u'I edit the value of a policy key and save$')
......@@ -51,9 +49,9 @@ def edit_the_value_of_a_policy_key_and_save(step):
change_display_name_value(step, '"foo"')
@step('I create a JSON object as a value$')
def create_JSON_object(step):
change_display_name_value(step, '{"key": "value", "key_2": "value_2"}')
@step('I create a JSON object as a value for "(.*)"$')
def create_JSON_object(step, key):
change_value(step, key, '{"key": "value", "key_2": "value_2"}')
@step('I create a non-JSON value not in quotes$')
......@@ -81,7 +79,12 @@ def they_are_alphabetized(step):
@step('it is displayed as formatted$')
def it_is_formatted(step):
assert_policy_entries([DISPLAY_NAME_KEY], ['{\n "key": "value",\n "key_2": "value_2"\n}'])
assert_policy_entries(['discussion_topics'], ['{\n "key": "value",\n "key_2": "value_2"\n}'])
@step('I get an error on save$')
def error_on_save(step):
assert_regexp_matches(world.css_text('#notification-error-description'), 'Incorrect setting format')
@step('it is displayed as a string')
......@@ -123,10 +126,9 @@ def get_display_name_value():
def change_display_name_value(step, new_value):
e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
display_name = get_display_name_value()
for count in range(len(display_name)):
e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE)
# Must delete "" before typing the JSON value
e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value)
change_value(step, DISPLAY_NAME_KEY, new_value)
def change_value(step, key, new_value):
type_in_codemirror(get_index_of(key), new_value)
press_the_notification_button(step, "Save")
#pylint: disable=C0111
#pylint: disable=W0621
# pylint: disable=C0111
# pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_true
from nose.tools import assert_equal
from xmodule.modulestore.django import _MODULESTORES, modulestore
from xmodule.templates import update_templates
from auth.authz import get_user_by_email
from selenium.webdriver.common.keys import Keys
......@@ -15,10 +12,15 @@ import time
from logging import getLogger
logger = getLogger(__name__)
_COURSE_NAME = 'Robot Super Course'
_COURSE_NUM = '999'
_COURSE_ORG = 'MITx'
########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$')
def i_visit_the_studio_homepage(step):
def i_visit_the_studio_homepage(_step):
# To make this go to port 8001, put
# LETTUCE_SERVER_PORT = 8001
# in your settings.py file.
......@@ -28,17 +30,17 @@ def i_visit_the_studio_homepage(step):
@step('I am logged into Studio$')
def i_am_logged_into_studio(step):
def i_am_logged_into_studio(_step):
log_into_studio()
@step('I confirm the alert$')
def i_confirm_with_ok(step):
def i_confirm_with_ok(_step):
world.browser.get_alert().accept()
@step(u'I press the "([^"]*)" delete icon$')
def i_press_the_category_delete_icon(step, category):
def i_press_the_category_delete_icon(_step, category):
if category == 'section':
css = 'a.delete-button.delete-section-button span.delete-icon'
elif category == 'subsection':
......@@ -49,37 +51,38 @@ def i_press_the_category_delete_icon(step, category):
@step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step):
def i_have_opened_a_new_course(_step):
open_new_course()
####### HELPER FUNCTIONS ##############
def open_new_course():
world.clear_courses()
create_studio_user()
log_into_studio()
create_a_course()
####### HELPER FUNCTIONS ##############
def create_studio_user(
uname='robot',
email='robot+studio@edx.org',
password='test',
is_staff=False):
studio_user = world.UserFactory.build(
studio_user = world.UserFactory(
username=uname,
email=email,
password=password,
is_staff=is_staff)
studio_user.set_password(password)
studio_user.save()
registration = world.RegistrationFactory(user=studio_user)
registration.register(studio_user)
registration.activate()
user_profile = world.UserProfileFactory(user=studio_user)
def fill_in_course_info(
name='Robot Super Course',
org='MITx',
num='101'):
name=_COURSE_NAME,
org=_COURSE_ORG,
num=_COURSE_NUM):
world.css_fill('.new-course-name', name)
world.css_fill('.new-course-org', org)
world.css_fill('.new-course-number', num)
......@@ -88,10 +91,7 @@ def fill_in_course_info(
def log_into_studio(
uname='robot',
email='robot+studio@edx.org',
password='test',
is_staff=False):
create_studio_user(uname=uname, email=email, is_staff=is_staff)
password='test'):
world.browser.cookies.delete()
world.visit('/')
......@@ -109,14 +109,14 @@ def log_into_studio(
def create_a_course():
c = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
world.CourseFactory.create(org=_COURSE_ORG, course=_COURSE_NUM, display_name=_COURSE_NAME)
# Add the user to the instructor group of the course
# so they will have the permissions to see it in studio
g = world.GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course')
u = get_user_by_email('robot+studio@edx.org')
u.groups.add(g)
u.save()
course = world.GroupFactory.create(name='instructor_MITx/{course_num}/{course_name}'.format(course_num=_COURSE_NUM, course_name=_COURSE_NAME.replace(" ", "_")))
user = get_user_by_email('robot+studio@edx.org')
user.groups.add(course)
user.save()
world.browser.reload()
course_link_css = 'span.class-name'
......@@ -149,8 +149,47 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
world.css_fill(date_css, desired_date)
# hit TAB to get to the time field
e = world.css_find(date_css).first
# pylint: disable=W0212
e._element.send_keys(Keys.TAB)
world.css_fill(time_css, desired_time)
e = world.css_find(time_css).first
e._element.send_keys(Keys.TAB)
time.sleep(float(1))
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',
'i4x://edx/templates/video/default',
'.xmodule_VideoModule'
)
@step('I have clicked the new unit button')
def open_new_unit(step):
step.given('I have opened a new course section in Studio')
step.given('I have added a new subsection')
step.given('I expand the first section')
world.css_click('a.new-unit-item')
@step('when I view the video it (.*) show the captions')
def shows_captions(step, show_captions):
# Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions')
if show_captions == 'does not':
assert world.css_find('.video')[0].has_class('closed')
else:
assert world.is_css_not_present('.video.closed')
def type_in_codemirror(index, text):
world.css_click(".CodeMirror", index=index)
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
if world.is_mac():
g._element.send_keys(Keys.COMMAND + 'a')
else:
g._element.send_keys(Keys.CONTROL + 'a')
g._element.send_keys(Keys.DELETE)
g._element.send_keys(text)
# disable missing docstring
#pylint: disable=C0111
from lettuce import world
from nose.tools import assert_equal
from terrain.steps import reload_the_page
@world.absorb
def create_component_instance(step, component_button_css, instance_id, expected_css):
click_new_component_button(step, component_button_css)
click_component_from_menu(instance_id, expected_css)
@world.absorb
def click_new_component_button(step, component_button_css):
step.given('I have clicked the new unit button')
world.css_click(component_button_css)
@world.absorb
def click_component_from_menu(instance_id, expected_css):
"""
Creates a component from `instance_id`. For components with more
than one template, clicks on `elem_css` to create the new
component. Components with only one template are created as soon
as the user clicks the appropriate button, so we assert that the
expected component is present.
"""
elem_css = "a[data-location='%s']" % instance_id
elements = world.css_find(elem_css)
assert(len(elements) == 1)
if elements[0]['id'] == instance_id: # If this is a component with multiple templates
world.css_click(elem_css)
assert_equal(1, len(world.css_find(expected_css)))
@world.absorb
def edit_component_and_select_settings():
world.css_click('a.edit-button')
world.css_click('#settings-mode')
@world.absorb
def verify_setting_entry(setting, display_name, value, explicitly_set):
assert_equal(display_name, setting.find_by_css('.setting-label')[0].value)
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
settingClearButton = setting.find_by_css('.setting-clear')[0]
assert_equal(explicitly_set, settingClearButton.has_class('active'))
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
@world.absorb
def verify_all_setting_entries(expected_entries):
settings = world.browser.find_by_css('.wrapper-comp-setting')
assert_equal(len(expected_entries), len(settings))
for (counter, setting) in enumerate(settings):
world.verify_setting_entry(
setting, expected_entries[counter][0],
expected_entries[counter][1], expected_entries[counter][2]
)
@world.absorb
def save_component_and_reopen(step):
world.css_click("a.save-button")
# We have a known issue that modifications are still shown within the edit window after cancel (though)
# they are not persisted. Refresh the browser to make sure the changes WERE persisted after Save.
reload_the_page(step)
edit_component_and_select_settings()
@world.absorb
def cancel_component(step):
world.css_click("a.cancel-button")
# We have a known issue that modifications are still shown within the edit window after cancel (though)
# they are not persisted. Refresh the browser to make sure the changes were not persisted.
reload_the_page(step)
@world.absorb
def revert_setting_entry(label):
get_setting_entry(label).find_by_css('.setting-clear')[0].click()
@world.absorb
def get_setting_entry(label):
settings = world.browser.find_by_css('.wrapper-comp-setting')
for setting in settings:
if setting.find_by_css('.setting-label')[0].value == label:
return setting
return None
Feature: Course Team
As a course author, I want to be able to add others to my team
Scenario: Users can add other users
Given I have opened a new course in Studio
And the user "alice" exists
And I am viewing the course team settings
When I add "alice" to the course team
And "alice" logs in
Then she does see the course on her page
Scenario: Added users cannot delete or add other users
Given I have opened a new course in Studio
And the user "bob" exists
And I am viewing the course team settings
When I add "bob" to the course team
And "bob" logs in
Then he cannot delete users
And he cannot add users
Scenario: Users can delete other users
Given I have opened a new course in Studio
And the user "carol" exists
And I am viewing the course team settings
When I add "carol" to the course team
And I delete "carol" from the course team
And "carol" logs in
Then she does not see the course on her page
Scenario: Users cannot add users that do not exist
Given I have opened a new course in Studio
And I am viewing the course team settings
When I add "dennis" to the course team
Then I should see "Could not find user by email address" somewhere on the page
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from common import create_studio_user, log_into_studio, _COURSE_NAME
PASSWORD = 'test'
EMAIL_EXTENSION = '@edx.org'
@step(u'I am viewing the course team settings')
def view_grading_settings(_step):
world.click_course_settings()
link_css = 'li.nav-course-settings-team a'
world.css_click(link_css)
@step(u'the user "([^"]*)" exists$')
def create_other_user(_step, name):
create_studio_user(uname=name, password=PASSWORD, email=(name + EMAIL_EXTENSION))
@step(u'I add "([^"]*)" to the course team')
def add_other_user(_step, name):
new_user_css = 'a.new-user-button'
world.css_click(new_user_css)
email_css = 'input.email-input'
f = world.css_find(email_css)
f._element.send_keys(name, EMAIL_EXTENSION)
confirm_css = '#add_user'
world.css_click(confirm_css)
@step(u'I delete "([^"]*)" from the course team')
def delete_other_user(_step, name):
to_delete_css = 'a.remove-user[data-id="{name}{extension}"]'.format(name=name, extension=EMAIL_EXTENSION)
world.css_click(to_delete_css)
@step(u'"([^"]*)" logs in$')
def other_user_login(_step, name):
log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION)
@step(u's?he does( not)? see the course on (his|her) page')
def see_course(_step, doesnt_see_course, gender):
class_css = 'span.class-name'
all_courses = world.css_find(class_css)
all_names = [item.html for item in all_courses]
if doesnt_see_course:
assert not _COURSE_NAME in all_names
else:
assert _COURSE_NAME in all_names
@step(u's?he cannot delete users')
def cannot_delete(_step):
to_delete_css = 'a.remove-user'
assert world.is_css_not_present(to_delete_css)
@step(u's?he cannot add users')
def cannot_add(_step):
add_css = 'a.new-user'
assert world.is_css_not_present(add_css)
Feature: Course updates
As a course author, I want to be able to provide updates to my students
Scenario: Users can add updates
Given I have opened a new course in Studio
And I go to the course updates page
When I add a new update with the text "Hello"
Then I should see the update "Hello"
Scenario: Users can edit updates
Given I have opened a new course in Studio
And I go to the course updates page
When I add a new update with the text "Hello"
And I modify the text to "Goodbye"
Then I should see the update "Goodbye"
Scenario: Users can delete updates
Given I have opened a new course in Studio
And I go to the course updates page
And I add a new update with the text "Hello"
When I will confirm all alerts
And I delete the update
Then I should not see the update "Hello"
Scenario: Users can edit update dates
Given I have opened a new course in Studio
And I go to the course updates page
And I add a new update with the text "Hello"
When I edit the date to "June 1, 2013"
Then I should see the date "June 1, 2013"
Scenario: Users can change handouts
Given I have opened a new course in Studio
And I go to the course updates page
When I modify the handout to "<ol>Test</ol>"
Then I see the handout "Test"
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from selenium.webdriver.common.keys import Keys
from common import type_in_codemirror
@step(u'I go to the course updates page')
def go_to_updates(_step):
menu_css = 'li.nav-course-courseware'
updates_css = 'li.nav-course-courseware-updates'
world.css_click(menu_css)
world.css_click(updates_css)
@step(u'I add a new update with the text "([^"]*)"$')
def add_update(_step, text):
update_css = 'a.new-update-button'
world.css_click(update_css)
change_text(text)
@step(u'I should( not)? see the update "([^"]*)"$')
def check_update(_step, doesnt_see_update, text):
update_css = 'div.update-contents'
update = world.css_find(update_css)
if doesnt_see_update:
assert len(update) == 0 or not text in update.html
else:
assert text in update.html
@step(u'I modify the text to "([^"]*)"$')
def modify_update(_step, text):
button_css = 'div.post-preview a.edit-button'
world.css_click(button_css)
change_text(text)
@step(u'I delete the update$')
def click_button(_step):
button_css = 'div.post-preview a.delete-button'
world.css_click(button_css)
@step(u'I edit the date to "([^"]*)"$')
def change_date(_step, new_date):
button_css = 'div.post-preview a.edit-button'
world.css_click(button_css)
date_css = 'input.date'
date = world.css_find(date_css)
for i in range(len(date.value)):
date._element.send_keys(Keys.END, Keys.BACK_SPACE)
date._element.send_keys(new_date)
save_css = 'a.save-button'
world.css_click(save_css)
@step(u'I should see the date "([^"]*)"$')
def check_date(_step, date):
date_css = 'span.date-display'
date_html = world.css_find(date_css)
assert date == date_html.html
@step(u'I modify the handout to "([^"]*)"$')
def edit_handouts(_step, text):
edit_css = 'div.course-handouts > a.edit-button'
world.css_click(edit_css)
change_text(text)
@step(u'I see the handout "([^"]*)"$')
def check_handout(_step, handout):
handout_css = 'div.handouts-content'
handouts = world.css_find(handout_css)
assert handout in handouts.html
def change_text(text):
type_in_codemirror(0, text)
save_css = 'a.save-button'
world.css_click(save_css)
......@@ -10,6 +10,7 @@ from common import *
@step('There are no courses$')
def no_courses(step):
world.clear_courses()
create_studio_user()
@step('I click the New Course button$')
......@@ -47,12 +48,6 @@ def i_see_the_course_in_my_courses(step):
assert world.css_has_text(course_css, 'Robot Super Course')
@step('the course is loaded$')
def course_is_loaded(step):
class_css = 'a.class-name'
assert world.css_has_text(course_css, 'Robot Super Cousre')
@step('I am on the "([^"]*)" tab$')
def i_am_on_tab(step, tab_name):
header_css = 'div.inner-wrapper h1'
......
Feature: Discussion Component Editor
As a course author, I want to be able to create discussion components.
Scenario: User can view metadata
Given I have created a Discussion Tag
And I edit and select Settings
Then I see three alphabetized settings and their expected values
Scenario: User can modify display name
Given I have created a Discussion Tag
And I edit and select Settings
Then I can modify the display name
And my display name change is persisted on save
Scenario: Creating a discussion takes a single click
Given I have clicked the new unit button
Then creating a discussion takes a single click
# disable missing docstring
#pylint: disable=C0111
from lettuce import world, step
@step('I have created a Discussion Tag$')
def i_created_discussion_tag(step):
world.create_component_instance(
step, '.large-discussion-icon',
'i4x://edx/templates/discussion/Discussion_Tag',
'.xmodule_DiscussionModule'
)
@step('I see three alphabetized settings and their expected values$')
def i_see_only_the_settings_and_values(step):
world.verify_all_setting_entries(
[
['Category', "Week 1", True],
['Display Name', "Discussion Tag", True],
['Subcategory', "Topic-Level Student-Visible Label", True]
])
@step('creating a discussion takes a single click')
def discussion_takes_a_single_click(step):
assert(not world.is_css_present('.xmodule_DiscussionModule'))
world.css_click("a[data-location='i4x://edx/templates/discussion/Discussion_Tag']")
assert(world.is_css_present('.xmodule_DiscussionModule'))
Feature: Course Grading
As a course author, I want to be able to configure how my course is graded
Scenario: Users can add grading ranges
Given I have opened a new course in Studio
And I am viewing the grading settings
When I add "1" new grade
Then I see I now have "3" grades
Scenario: Users can only have up to 5 grading ranges
Given I have opened a new course in Studio
And I am viewing the grading settings
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
Scenario: Users can delete grading ranges
Given I have opened a new course in Studio
And I am viewing the grading settings
When I add "1" new grade
And I delete a grade
Then I see I now have "2" grades
Scenario: Users can move grading ranges
Given I have opened a new course in Studio
And I am viewing the grading settings
When I move a grading section
Then I see that the grade range has changed
Scenario: Users can modify Assignment types
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 assignment type "Homework" to "New Type"
And I go back to the main course page
Then I do see the assignment name "New Type"
And I do not see the assignment name "Homework"
Scenario: Users can delete Assignment types
Given I have opened a new course in Studio
And I have populated the course
And I am viewing the grading settings
When I delete the assignment type "Homework"
And I go back to the main course page
Then I do not see the assignment name "Homework"
Scenario: Users can add Assignment types
Given I have opened a new course in Studio
And I have populated the course
And I am viewing the grading settings
When I add a new assignment type "New Type"
And I go back to the main course page
Then I do see the assignment name "New Type"
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from common import *
@step(u'I am viewing the grading settings')
def view_grading_settings(step):
world.click_course_settings()
link_css = 'li.nav-course-settings-grading a'
world.css_click(link_css)
@step(u'I add "([^"]*)" new grade')
def add_grade(step, many):
grade_css = '.new-grade-button'
for i in range(int(many)):
world.css_click(grade_css)
@step(u'I delete a grade')
def delete_grade(step):
#grade_css = 'li.grade-specific-bar > a.remove-button'
#range_css = '.grade-specific-bar'
#world.css_find(range_css)[1].mouseover()
#world.css_click(grade_css)
world.browser.execute_script('document.getElementsByClassName("remove-button")[0].click()')
@step(u'I see I now have "([^"]*)" grades$')
def view_grade_slider(step, how_many):
grade_slider_css = '.grade-specific-bar'
all_grades = world.css_find(grade_slider_css)
assert len(all_grades) == int(how_many)
@step(u'I move a grading section')
def move_grade_slider(step):
moveable_css = '.ui-resizable-e'
f = world.css_find(moveable_css).first
f.action_chains.drag_and_drop_by_offset(f._element, 100, 0).perform()
@step(u'I see that the grade range has changed')
def confirm_change(step):
range_css = '.range'
all_ranges = world.css_find(range_css)
for i in range(len(all_ranges)):
assert all_ranges[i].html != '0-50'
@step(u'I change assignment type "([^"]*)" to "([^"]*)"$')
def change_assignment_name(step, old_name, new_name):
name_id = '#course-grading-assignment-name'
index = get_type_index(old_name)
f = world.css_find(name_id)[index]
assert index != -1
for count in range(len(old_name)):
f._element.send_keys(Keys.END, Keys.BACK_SPACE)
f._element.send_keys(new_name)
@step(u'I go back to the main course page')
def main_course_page(step):
main_page_link_css = 'a[href="/MITx/999/course/Robot_Super_Course"]'
world.css_click(main_page_link_css)
@step(u'I do( not)? see the assignment name "([^"]*)"$')
def see_assignment_name(step, do_not, name):
assignment_menu_css = 'ul.menu > li > a'
assignment_menu = world.css_find(assignment_menu_css)
allnames = [item.html for item in assignment_menu]
if do_not:
assert not name in allnames
else:
assert name in allnames
@step(u'I delete the assignment type "([^"]*)"$')
def delete_assignment_type(step, to_delete):
delete_css = '.remove-grading-data'
world.css_click(delete_css, index=get_type_index(to_delete))
@step(u'I add a new assignment type "([^"]*)"$')
def add_assignment_type(step, new_name):
add_button_css = '.add-grading-data'
world.css_click(add_button_css)
name_id = '#course-grading-assignment-name'
f = world.css_find(name_id)[4]
f._element.send_keys(new_name)
@step(u'I have populated the course')
def populate_course(step):
step.given('I have added a new section')
step.given('I have added a new subsection')
def get_type_index(name):
name_id = '#course-grading-assignment-name'
f = world.css_find(name_id)
for i in range(len(f)):
if f[i].value == name:
return i
return -1
Feature: HTML Editor
As a course author, I want to be able to create HTML blocks.
Scenario: User can view metadata
Given I have created a Blank HTML Page
And I edit and select Settings
Then I see only the HTML display name setting
Scenario: User can modify display name
Given I have created a Blank HTML Page
And I edit and select Settings
Then I can modify the display name
And my display name change is persisted on save
# disable missing docstring
#pylint: disable=C0111
from lettuce import world, step
@step('I have created a Blank HTML Page$')
def i_created_blank_html_page(step):
world.create_component_instance(
step, '.large-html-icon', 'i4x://edx/templates/html/Blank_HTML_Page',
'.xmodule_HtmlModule'
)
@step('I see only the HTML display name setting$')
def i_see_only_the_html_display_name(step):
world.verify_all_setting_entries([['Display Name', "Blank HTML Page", True]])
Feature: Problem Editor
As a course author, I want to be able to create problems and edit their settings.
Scenario: User can view metadata
Given I have created a Blank Common Problem
When I edit and select Settings
Then I see five alphabetized settings and their expected values
And Edit High Level Source is not visible
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
Scenario: User can specify special characters in String values
Given I have created a Blank Common Problem
When I edit and select Settings
Then I can specify special characters in the display name
And my special characters and persisted on save
Scenario: User can revert display name to unset
Given I have created a Blank Common Problem
When I edit and select Settings
Then I can revert the display name to unset
And my display name is unset on save
Scenario: User can select values in a Select
Given I have created a Blank Common Problem
When I edit and select Settings
Then I can select Per Student for Randomization
And my change to randomization is persisted
And I can revert to the default value for randomization
Scenario: User can modify float input values
Given I have created a Blank Common Problem
When I edit and select Settings
Then I can set the weight to "3.5"
And my change to weight is persisted
And I can revert to the default value of unset for weight
Scenario: User cannot type letters in float number field
Given I have created a Blank Common Problem
When I edit and select Settings
Then if I set the weight to "abc", it remains unset
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 displays initially as "234", and is persisted as "234"
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 displays initially as "-3", and is persisted as "0"
Scenario: Settings changes are not saved on Cancel
Given I have created a Blank Common Problem
When I edit and select Settings
Then I can set the weight to "3.5"
And I can modify the display name
Then If I press Cancel my changes are not persisted
Scenario: Edit High Level source is available for LaTeX problem
Given I have created a LaTeX Problem
When I edit and select Settings
Then Edit High Level Source is visible
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
Then my change to the High Level Source is persisted
And when I view the High Level Source I see my changes
# disable missing docstring
#pylint: disable=C0111
from lettuce import world, step
from nose.tools import assert_equal
from common import type_in_codemirror
DISPLAY_NAME = "Display Name"
MAXIMUM_ATTEMPTS = "Maximum Attempts"
PROBLEM_WEIGHT = "Problem Weight"
RANDOMIZATION = 'Randomization'
SHOW_ANSWER = "Show Answer"
############### ACTIONS ####################
@step('I have created a Blank Common Problem$')
def i_created_blank_common_problem(step):
world.create_component_instance(
step,
'.large-problem-icon',
'i4x://edx/templates/problem/Blank_Common_Problem',
'.xmodule_CapaModule'
)
@step('I edit and select Settings$')
def i_edit_and_select_settings(step):
world.edit_component_and_select_settings()
@step('I see five alphabetized settings and their expected values$')
def i_see_five_settings_with_values(step):
world.verify_all_setting_entries(
[
[DISPLAY_NAME, "Blank Common Problem", True],
[MAXIMUM_ATTEMPTS, "", False],
[PROBLEM_WEIGHT, "", False],
[RANDOMIZATION, "Never", True],
[SHOW_ANSWER, "Finished", True]
])
@step('I can modify the display name')
def i_can_modify_the_display_name(step):
# Verifying that the display name can be a string containing a floating point value
# (to confirm that we don't throw an error because it is of the wrong type).
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('3.4')
verify_modified_display_name()
@step('my display name change is persisted on save')
def my_display_name_change_is_persisted_on_save(step):
world.save_component_and_reopen(step)
verify_modified_display_name()
@step('I can specify special characters in the display name')
def i_can_modify_the_display_name_with_special_chars(step):
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill("updated ' \" &")
verify_modified_display_name_with_special_chars()
@step('my special characters and persisted on save')
def special_chars_persisted_on_save(step):
world.save_component_and_reopen(step)
verify_modified_display_name_with_special_chars()
@step('I can revert the display name to unset')
def can_revert_display_name_to_unset(step):
world.revert_setting_entry(DISPLAY_NAME)
verify_unset_display_name()
@step('my display name is unset on save')
def my_display_name_is_persisted_on_save(step):
world.save_component_and_reopen(step)
verify_unset_display_name()
@step('I can select Per Student for Randomization')
def i_can_select_per_student_for_randomization(step):
world.browser.select(RANDOMIZATION, "Per Student")
verify_modified_randomization()
@step('my change to randomization is persisted')
def my_change_to_randomization_is_persisted(step):
world.save_component_and_reopen(step)
verify_modified_randomization()
@step('I can revert to the default value for randomization')
def i_can_revert_to_default_for_randomization(step):
world.revert_setting_entry(RANDOMIZATION)
world.save_component_and_reopen(step)
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Always", False)
@step('I can set the weight to "(.*)"?')
def i_can_set_weight(step, weight):
set_weight(weight)
verify_modified_weight()
@step('my change to weight is persisted')
def my_change_to_weight_is_persisted(step):
world.save_component_and_reopen(step)
verify_modified_weight()
@step('I can revert to the default value of unset for weight')
def i_can_revert_to_default_for_unset_weight(step):
world.revert_setting_entry(PROBLEM_WEIGHT)
world.save_component_and_reopen(step)
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False)
@step('if I set the weight to "(.*)", it remains unset')
def set_the_weight_to_abc(step, bad_weight):
set_weight(bad_weight)
# We show the clear button immediately on type, hence the "True" here.
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", True)
world.save_component_and_reopen(step)
# But no change was actually ever sent to the model, so on reopen, explicitly_set is False
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False)
@step('if I set the max attempts to "(.*)", it displays initially as "(.*)", and is persisted as "(.*)"')
def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted):
world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set)
world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True)
world.save_component_and_reopen(step)
world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_persisted, True)
@step('Edit High Level Source is not visible')
def edit_high_level_source_not_visible(step):
verify_high_level_source_links(step, False)
@step('Edit High Level Source is visible')
def edit_high_level_source_links_visible(step):
verify_high_level_source_links(step, True)
@step('If I press Cancel my changes are not persisted')
def cancel_does_not_save_changes(step):
world.cancel_component(step)
step.given("I edit and select Settings")
step.given("I see five alphabetized settings and their expected values")
@step('I have created a LaTeX Problem')
def create_latex_problem(step):
world.click_new_component_button(step, '.large-problem-icon')
# Go to advanced tab.
world.css_click('#ui-id-2')
world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule')
@step('I edit and compile the High Level Source')
def edit_latex_source(step):
open_high_level_source()
type_in_codemirror(1, "hi")
world.css_click('.hls-compile')
@step('my change to the High Level Source is persisted')
def high_level_source_persisted(step):
def verify_text(driver):
return world.css_find('.problem').text == 'hi'
world.wait_for(verify_text)
@step('I view the High Level Source I see my changes')
def high_level_source_in_editor(step):
open_high_level_source()
assert_equal('hi', world.css_find('.source-edit-box').value)
def verify_high_level_source_links(step, visible):
assert_equal(visible, world.is_css_present('.launch-latex-compiler'))
world.cancel_component(step)
assert_equal(visible, world.is_css_present('.upload-button'))
def verify_modified_weight():
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "3.5", True)
def verify_modified_randomization():
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Per Student", True)
def verify_modified_display_name():
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '3.4', True)
def verify_modified_display_name_with_special_chars():
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, "updated ' \" &", True)
def verify_unset_display_name():
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False)
def set_weight(weight):
world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight)
def open_high_level_source():
world.css_click('a.edit-button')
world.css_click('.launch-latex-compiler > a')
......@@ -26,11 +26,9 @@ Feature: Create Section
And I save a new section release date
Then the section release date is updated
# Skipped because Ubuntu ChromeDriver hangs on alert
@skip
Scenario: Delete section
Given I have opened a new course in Studio
And I have added a new section
When I press the "section" delete icon
And I confirm the alert
When I will confirm all alerts
And I press the "section" delete icon
Then the section does not exist
#pylint: disable=C0111
#pylint: disable=W0621
# pylint: disable=C0111
# pylint: disable=W0621
from lettuce import world, step
from common import *
......@@ -8,35 +8,35 @@ from nose.tools import assert_equal
############### ACTIONS ####################
@step('I click the new section link$')
def i_click_new_section_link(step):
@step('I click the New Section link$')
def i_click_new_section_link(_step):
link_css = 'a.new-courseware-section-button'
world.css_click(link_css)
@step('I enter the section name and click save$')
def i_save_section_name(step):
def i_save_section_name(_step):
save_section_name('My Section')
@step('I enter a section name with a quote and click save$')
def i_save_section_name_with_quote(step):
def i_save_section_name_with_quote(_step):
save_section_name('Section with "Quote"')
@step('I have added a new section$')
def i_have_added_new_section(step):
def i_have_added_new_section(_step):
add_section()
@step('I click the Edit link for the release date$')
def i_click_the_edit_link_for_the_release_date(step):
def i_click_the_edit_link_for_the_release_date(_step):
button_css = 'div.section-published-date a.edit-button'
world.css_click(button_css)
@step('I save a new section release date$')
def i_save_a_new_section_release_date(step):
def i_save_a_new_section_release_date(_step):
set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013',
'input.start-time.time.ui-timepicker-input', '00:00')
world.browser.click_link_by_text('Save')
......@@ -46,35 +46,35 @@ def i_save_a_new_section_release_date(step):
@step('I see my section on the Courseware page$')
def i_see_my_section_on_the_courseware_page(step):
def i_see_my_section_on_the_courseware_page(_step):
see_my_section_on_the_courseware_page('My Section')
@step('I see my section name with a quote on the Courseware page$')
def i_see_my_section_name_with_quote_on_the_courseware_page(step):
def i_see_my_section_name_with_quote_on_the_courseware_page(_step):
see_my_section_on_the_courseware_page('Section with "Quote"')
@step('I click to edit the section name$')
def i_click_to_edit_section_name(step):
def i_click_to_edit_section_name(_step):
world.css_click('span.section-name-span')
@step('I see the complete section name with a quote in the editor$')
def i_see_complete_section_name_with_quote_in_editor(step):
def i_see_complete_section_name_with_quote_in_editor(_step):
css = '.section-name-edit input[type=text]'
assert world.is_css_present(css)
assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
@step('the section does not exist$')
def section_does_not_exist(step):
css = 'span.section-name-span'
assert world.browser.is_element_not_present_by_css(css)
def section_does_not_exist(_step):
css = 'h3[data-name="My Section"]'
assert world.is_css_not_present(css)
@step('I see a release date for my section$')
def i_see_a_release_date_for_my_section(step):
def i_see_a_release_date_for_my_section(_step):
import re
css = 'span.published-status'
......@@ -83,26 +83,32 @@ def i_see_a_release_date_for_my_section(step):
# e.g. 11/06/2012 at 16:25
msg = 'Will Release:'
date_regex = '[01][0-9]\/[0-3][0-9]\/[12][0-9][0-9][0-9]'
time_regex = '[0-2][0-9]:[0-5][0-9]'
match_string = '%s %s at %s' % (msg, date_regex, time_regex)
date_regex = r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d?, \d{4}'
if not re.search(date_regex, status_text):
print status_text, date_regex
time_regex = r'[0-2]\d:[0-5]\d( \w{3})?'
if not re.search(time_regex, status_text):
print status_text, time_regex
match_string = r'%s\s+%s at %s' % (msg, date_regex, time_regex)
if not re.match(match_string, status_text):
print status_text, match_string
assert re.match(match_string, status_text)
@step('I see a link to create a new subsection$')
def i_see_a_link_to_create_a_new_subsection(step):
def i_see_a_link_to_create_a_new_subsection(_step):
css = 'a.new-subsection-item'
assert world.is_css_present(css)
@step('the section release date picker is not visible$')
def the_section_release_date_picker_not_visible(step):
def the_section_release_date_picker_not_visible(_step):
css = 'div.edit-subsection-publish-settings'
assert not world.css_visible(css)
@step('the section release date is updated$')
def the_section_release_date_is_updated(step):
def the_section_release_date_is_updated(_step):
css = 'span.published-status'
status_text = world.css_text(css)
assert_equal(status_text, 'Will Release: 12/25/2013 at 00:00 UTC')
......
Feature: Static Pages
As a course author, I want to be able to add static pages
Scenario: Users can add static pages
Given I have opened a new course in Studio
And I go to the static pages page
When I add a new page
Then I should see a "Empty" static page
Scenario: Users can delete static pages
Given I have opened a new course in Studio
And I go to the static pages page
And I add a new page
When I will confirm all alerts
And I "delete" the "Empty" page
Then I should not see a "Empty" static page
Scenario: Users can edit static pages
Given I have opened a new course in Studio
And I go to the static pages page
And I add a new page
When I "edit" the "Empty" page
And I change the name to "New"
Then I should see a "New" static page
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from selenium.webdriver.common.keys import Keys
@step(u'I go to the static pages page')
def go_to_static(_step):
menu_css = 'li.nav-course-courseware'
static_css = 'li.nav-course-courseware-pages'
world.css_find(menu_css).click()
world.css_find(static_css).click()
@step(u'I add a new page')
def add_page(_step):
button_css = 'a.new-button'
world.css_find(button_css).click()
@step(u'I should( not)? see a "([^"]*)" static page$')
def see_page(_step, doesnt, page):
index = get_index(page)
if doesnt:
assert index == -1
else:
assert index != -1
@step(u'I "([^"]*)" the "([^"]*)" page$')
def click_edit_delete(_step, edit_delete, page):
button_css = 'a.%s-button' % edit_delete
index = get_index(page)
assert index != -1
world.css_find(button_css)[index].click()
@step(u'I change the name to "([^"]*)"$')
def change_name(_step, new_name):
settings_css = '#settings-mode'
world.css_find(settings_css).click()
input_css = 'input.setting-input'
name_input = world.css_find(input_css)
old_name = name_input.value
for count in range(len(old_name)):
name_input._element.send_keys(Keys.END, Keys.BACK_SPACE)
name_input._element.send_keys(new_name)
save_button = 'a.save-button'
world.css_find(save_button).click()
def get_index(name):
page_name_css = 'section[data-type="HTMLModule"]'
all_pages = world.css_find(page_name_css)
for i in range(len(all_pages)):
if all_pages[i].html == '\n {name}\n'.format(name=name):
return i
return -1
Feature: Overview Toggle Section
In order to quickly view the details of a course's section or to scan the inventory of sections
In order to quickly view the details of a course's section or to scan the inventory of sections
As a course author
I want to toggle the visibility of each section's subsection details in the overview listing
Scenario: The default layout for the overview page is to show sections in expanded view
Given I have a course with multiple sections
When I navigate to the course overview page
Then I see the "Collapse All Sections" link
And all sections are expanded
Scenario: Expand /collapse for a course with no sections
Given I have a course with no sections
When I navigate to the course overview page
Then I do not see the "Collapse All Sections" link
Scenario: Collapse link appears after creating first section of a course
Given I have a course with no sections
When I navigate to the course overview page
And I add a section
Then I see the "Collapse All Sections" link
And all sections are expanded
# Skipped because Ubuntu ChromeDriver hangs on alert
@skip
Scenario: Collapse link is not removed after last section of a course is deleted
Given I have a course with 1 section
And I navigate to the course overview page
When I press the "section" delete icon
And I confirm the alert
Then I see the "Collapse All Sections" link
Scenario: Collapsing all sections when all sections are expanded
Given I navigate to the courseware page of a course with multiple sections
And all sections are expanded
When I click the "Collapse All Sections" link
Then I see the "Expand All Sections" link
And all sections are collapsed
Scenario: Collapsing all sections when 1 or more sections are already collapsed
Given I navigate to the courseware page of a course with multiple sections
And all sections are expanded
When I collapse the first section
And I click the "Collapse All Sections" link
Then I see the "Expand All Sections" link
And all sections are collapsed
Scenario: Expanding all sections when all sections are collapsed
Given I navigate to the courseware page of a course with multiple sections
And I click the "Collapse All Sections" link
When I click the "Expand All Sections" link
Then I see the "Collapse All Sections" link
And all sections are expanded
Scenario: Expanding all sections when 1 or more sections are already expanded
Given I navigate to the courseware page of a course with multiple sections
And I click the "Collapse All Sections" link
When I expand the first section
And I click the "Expand All Sections" link
Then I see the "Collapse All Sections" link
And all sections are expanded
Scenario: The default layout for the overview page is to show sections in expanded view
Given I have a course with multiple sections
When I navigate to the course overview page
Then I see the "Collapse All Sections" link
And all sections are expanded
Scenario: Expand /collapse for a course with no sections
Given I have a course with no sections
When I navigate to the course overview page
Then I do not see the "Collapse All Sections" link
Scenario: Collapse link appears after creating first section of a course
Given I have a course with no sections
When I navigate to the course overview page
And I add a section
Then I see the "Collapse All Sections" link
And all sections are expanded
Scenario: Collapse link is not removed after last section of a course is deleted
Given I have a course with 1 section
And I navigate to the course overview page
When I will confirm all alerts
And I press the "section" delete icon
Then I see the "Collapse All Sections" link
Scenario: Collapsing all sections when all sections are expanded
Given I navigate to the courseware page of a course with multiple sections
And all sections are expanded
When I click the "Collapse All Sections" link
Then I see the "Expand All Sections" link
And all sections are collapsed
Scenario: Collapsing all sections when 1 or more sections are already collapsed
Given I navigate to the courseware page of a course with multiple sections
And all sections are expanded
When I collapse the first section
And I click the "Collapse All Sections" link
Then I see the "Expand All Sections" link
And all sections are collapsed
Scenario: Expanding all sections when all sections are collapsed
Given I navigate to the courseware page of a course with multiple sections
And I click the "Collapse All Sections" link
When I click the "Expand All Sections" link
Then I see the "Collapse All Sections" link
And all sections are expanded
Scenario: Expanding all sections when 1 or more sections are already expanded
Given I navigate to the courseware page of a course with multiple sections
And I click the "Collapse All Sections" link
When I expand the first section
And I click the "Expand All Sections" link
Then I see the "Collapse All Sections" link
And all sections are expanded
#pylint: disable=C0111
#pylint: disable=W0621
# pylint: disable=C0111
# pylint: disable=W0621
from lettuce import world, step
from common import *
......@@ -50,7 +50,8 @@ def have_a_course_with_two_sections(step):
@step(u'I navigate to the course overview page$')
def navigate_to_the_course_overview_page(step):
log_into_studio(is_staff=True)
create_studio_user(is_staff=True)
log_into_studio()
course_locator = '.class-name'
world.css_click(course_locator)
......@@ -112,7 +113,7 @@ def all_sections_are_expanded(step):
@step(u'all sections are collapsed$')
def all_sections_are_expanded(step):
def all_sections_are_collapsed(step):
subsection_locator = 'div.subsection-list'
subsections = world.css_find(subsection_locator)
for s in subsections:
......
......@@ -32,12 +32,10 @@ Feature: Create Subsection
And I reload the page
Then I see the correct dates
# Skipped because Ubuntu ChromeDriver hangs on alert
@skip
Scenario: Delete a subsection
Given I have opened a new course section in Studio
And I have added a new subsection
And I see my subsection on the Courseware page
When I press the "subsection" delete icon
And I confirm the alert
When I will confirm all alerts
And I press the "subsection" delete icon
Then the subsection does not exist
......@@ -10,9 +10,7 @@ from nose.tools import assert_equal
@step('I have opened a new course section in Studio$')
def i_have_opened_a_new_course_section(step):
world.clear_courses()
log_into_studio()
create_a_course()
open_new_course()
add_section()
......
Feature: Upload Files
As a course author, I want to be able to upload files for my students
Scenario: Users can upload 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 should see the file "test" was uploaded
And The url for the file "test" is valid
Scenario: Users can update 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"
And I upload the file "test"
Then I should see only one "test"
Scenario: Users can delete uploaded 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"
And I delete the file "test"
Then I should not see the file "test" was uploaded
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
Scenario: Users can download updated 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"
And I modify "test"
And I reload the page
And I upload the file "test"
Then I can download the correct "test" file
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from django.conf import settings
import requests
import string
import random
import os
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
HTTP_PREFIX = "http://localhost:8001"
@step(u'I go to the files and uploads page')
def go_to_uploads(_step):
menu_css = 'li.nav-course-courseware'
uploads_css = 'li.nav-course-courseware-uploads'
world.css_find(menu_css).click()
world.css_find(uploads_css).click()
@step(u'I upload the file "([^"]*)"$')
def upload_file(_step, file_name):
upload_css = 'a.upload-button'
world.css_find(upload_css).click()
file_css = 'input.file-input'
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))
close_css = 'a.close-button'
world.css_find(close_css).click()
@step(u'I should( not)? see the file "([^"]*)" was uploaded$')
def check_upload(_step, do_not_see_file, file_name):
index = get_index(file_name)
if do_not_see_file:
assert index == -1
else:
assert index != -1
@step(u'The url for the file "([^"]*)" is valid$')
def check_url(_step, file_name):
r = get_file(file_name)
assert r.status_code == 200
@step(u'I delete the file "([^"]*)"$')
def delete_file(_step, file_name):
index = get_index(file_name)
assert index != -1
delete_css = "a.remove-asset-button"
world.css_click(delete_css, index=index)
prompt_confirm_css = 'li.nav-item > a.action-primary'
world.css_click(prompt_confirm_css)
@step(u'I should see only one "([^"]*)"$')
def no_duplicate(_step, file_name):
names_css = 'td.name-col > a.filename'
all_names = world.css_find(names_css)
only_one = False
for i in range(len(all_names)):
if file_name == all_names[i].html:
only_one = not only_one
assert only_one
@step(u'I can download the correct "([^"]*)" file$')
def check_download(_step, file_name):
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
with open(os.path.abspath(path), 'r') as cur_file:
cur_text = cur_file.read()
r = get_file(file_name)
downloaded_text = r.text
assert cur_text == downloaded_text
@step(u'I modify "([^"]*)"$')
def modify_upload(_step, file_name):
new_text = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(10))
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
with open(os.path.abspath(path), 'w') as cur_file:
cur_file.write(new_text)
def get_index(file_name):
names_css = 'td.name-col > a.filename'
all_names = world.css_find(names_css)
for i in range(len(all_names)):
if file_name == all_names[i].html:
return i
return -1
def get_file(file_name):
index = get_index(file_name)
assert index != -1
url_css = 'input.embeddable-xml-input'
url = world.css_find(url_css)[index].value
return requests.get(HTTP_PREFIX + url)
Feature: Video Component Editor
As a course author, I want to be able to create video components.
Scenario: User can view metadata
Given I have created a Video component
And I edit and select Settings
Then I see the correct settings and default values
Scenario: User can modify display name
Given I have created a Video component
And I edit and select Settings
Then I can modify the display name
And my display name change is persisted on save
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
Scenario: Captions are shown when "show captions" is true
Given I have created a Video component
And I have set "show captions" to True
Then when I view the video it does show the captions
# disable missing docstring
#pylint: disable=C0111
from lettuce import world, step
@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', 'default', True],
['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 (.*)')
def set_show_captions(step, setting):
world.css_click('a.edit-button')
world.browser.select('Show Captions', setting)
world.css_click('a.save-button')
Feature: Video Component
As a course author, I want to be able to view my created videos in Studio.
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
Scenario: Creating a video takes a single click
Given I have clicked the new unit button
Then creating a video takes a single click
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
Scenario: Captions are shown correctly
Given I have created a Video component
Then when I view the video it does show the captions
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
#pylint: disable=C0111
from lettuce import world, step
############### ACTIONS ####################
@step('when I view the video it does not have autoplay enabled')
def does_not_autoplay(_step):
assert world.css_find('.video')[0]['data-autoplay'] == 'False'
assert world.css_find('.video_control')[0].has_class('play')
@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-location='i4x://edx/templates/video/default']")
assert(world.is_css_present('.xmodule_VideoModule'))
@step('I have (hidden|toggled) captions')
def hide_or_show_captions(step, shown):
button_css = 'a.hide-subtitles'
if shown == 'hidden':
world.css_click(button_css)
if shown == 'toggled':
world.css_click(button_css)
# When we click the first time, a tooltip shows up. We want to
# click the button rather than the tooltip, so move the mouse
# away to make it disappear.
button = world.css_find(button_css)
button.mouse_out()
world.css_click(button_css)
from django.core.management.base import BaseCommand, CommandError
from xmodule.course_module import CourseDescriptor
from xmodule.contentstore.utils import empty_asset_trashcan
from xmodule.modulestore.django import modulestore
from .prompt import query_yes_no
class Command(BaseCommand):
help = '''Empty the trashcan. Can pass an optional course_id to limit the damage.'''
def handle(self, *args, **options):
if len(args) != 1 and len(args) != 0:
raise CommandError("empty_asset_trashcan requires one or no arguments: |<location>|")
locs = []
if len(args) == 1:
locs.append(CourseDescriptor.id_to_location(args[0]))
else:
courses = modulestore('direct').get_courses()
for course in courses:
locs.append(course.location)
if query_yes_no("Emptying trashcan. Confirm?", default="no"):
empty_asset_trashcan(locs)
from django.core.management.base import BaseCommand, CommandError
from xmodule.contentstore.utils import restore_asset_from_trashcan
class Command(BaseCommand):
help = '''Restore a deleted asset from the trashcan back to it's original course'''
def handle(self, *args, **options):
if len(args) != 1 and len(args) != 0:
raise CommandError("restore_asset_from_trashcan requires one argument: <location>")
restore_asset_from_trashcan(args[0])
......@@ -39,10 +39,7 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
def set_module_info(store, location, post_data):
module = None
try:
if location.revision is None:
module = store.get_item(location)
else:
module = store.get_item(location)
module = store.get_item(location)
except:
pass
......
......@@ -19,6 +19,23 @@ class ChecklistTestCase(CourseTestCase):
modulestore = get_modulestore(self.course.location)
return modulestore.get_item(self.course.location).checklists
def compare_checklists(self, persisted, request):
"""
Handles url expansion as possible difference and descends into guts
:param persisted:
:param request:
"""
self.assertEqual(persisted['short_description'], request['short_description'])
compare_urls = (persisted.get('action_urls_expanded') == request.get('action_urls_expanded'))
for pers, req in zip(persisted['items'], request['items']):
self.assertEqual(pers['short_description'], req['short_description'])
self.assertEqual(pers['long_description'], req['long_description'])
self.assertEqual(pers['is_checked'], req['is_checked'])
if compare_urls:
self.assertEqual(pers['action_url'], req['action_url'])
self.assertEqual(pers['action_text'], req['action_text'])
self.assertEqual(pers['action_external'], req['action_external'])
def test_get_checklists(self):
""" Tests the get checklists method. """
checklists_url = get_url_reverse('Checklists', self.course)
......@@ -31,9 +48,9 @@ class ChecklistTestCase(CourseTestCase):
self.course.checklists = None
modulestore = get_modulestore(self.course.location)
modulestore.update_metadata(self.course.location, own_metadata(self.course))
self.assertEquals(self.get_persisted_checklists(), None)
self.assertEqual(self.get_persisted_checklists(), None)
response = self.client.get(checklists_url)
self.assertEquals(payload, response.content)
self.assertEqual(payload, response.content)
def test_update_checklists_no_index(self):
""" No checklist index, should return all of them. """
......@@ -43,7 +60,8 @@ class ChecklistTestCase(CourseTestCase):
'name': self.course.location.name})
returned_checklists = json.loads(self.client.get(update_url).content)
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
for pay, resp in zip(self.get_persisted_checklists(), returned_checklists):
self.compare_checklists(pay, resp)
def test_update_checklists_index_ignored_on_get(self):
""" Checklist index ignored on get. """
......@@ -53,7 +71,8 @@ class ChecklistTestCase(CourseTestCase):
'checklist_index': 1})
returned_checklists = json.loads(self.client.get(update_url).content)
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
for pay, resp in zip(self.get_persisted_checklists(), returned_checklists):
self.compare_checklists(pay, resp)
def test_update_checklists_post_no_index(self):
""" No checklist index, will error on post. """
......@@ -78,13 +97,18 @@ class ChecklistTestCase(CourseTestCase):
'course': self.course.location.course,
'name': self.course.location.name,
'checklist_index': 2})
def get_first_item(checklist):
return checklist['items'][0]
payload = self.course.checklists[2]
self.assertFalse(payload.get('is_checked'))
payload['is_checked'] = True
self.assertFalse(get_first_item(payload).get('is_checked'))
get_first_item(payload)['is_checked'] = True
returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content)
self.assertTrue(returned_checklist.get('is_checked'))
self.assertEqual(self.get_persisted_checklists()[2], returned_checklist)
self.assertTrue(get_first_item(returned_checklist).get('is_checked'))
pers = self.get_persisted_checklists()
self.compare_checklists(pers[2], returned_checklist)
def test_update_checklists_delete_unsupported(self):
""" Delete operation is not supported. """
......@@ -93,4 +117,4 @@ class ChecklistTestCase(CourseTestCase):
'name': self.course.location.name,
'checklist_index': 100})
response = self.client.delete(update_url)
self.assertContains(response, 'Unsupported request', status_code=400)
\ No newline at end of file
self.assertContains(response, 'Unsupported request', status_code=400)
"""
Tests for Studio Course Settings.
"""
import datetime
import json
import copy
import mock
from django.contrib.auth.models import User
from django.test.client import Client
from django.core.urlresolvers import reverse
from django.utils.timezone import UTC
from django.test.utils import override_settings
from xmodule.modulestore import Location
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
......@@ -21,6 +26,9 @@ from xmodule.fields import Date
class CourseTestCase(ModuleStoreTestCase):
"""
Base class for test classes below.
"""
def setUp(self):
"""
These tests need a user in the DB so that the django Test Client
......@@ -51,9 +59,13 @@ class CourseTestCase(ModuleStoreTestCase):
class CourseDetailsTestCase(CourseTestCase):
"""
Tests the first course settings page (course dates, overview, etc.).
"""
def test_virgin_fetch(self):
details = CourseDetails.fetch(self.course_location)
self.assertEqual(details.course_location, self.course_location, "Location not copied into")
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))
self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end))
......@@ -67,7 +79,6 @@ class CourseDetailsTestCase(CourseTestCase):
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
jsondetails = json.loads(jsondetails)
self.assertTupleEqual(Location(jsondetails['course_location']), self.course_location, "Location !=")
# Note, start_date is being initialized someplace. I'm not sure why b/c the default will make no sense.
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 ")
......@@ -76,6 +87,23 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
def test_ooc_encoder(self):
"""
Test the encoder out of its original constrained purpose to see if it functions for general use
"""
details = {'location': Location(['tag', 'org', 'course', 'category', 'name']),
'number': 1,
'string': 'string',
'datetime': datetime.datetime.now(UTC())}
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
jsondetails = json.loads(jsondetails)
self.assertIn('location', jsondetails)
self.assertIn('org', jsondetails['location'])
self.assertEquals('org', jsondetails['location'][1])
self.assertEquals(1, jsondetails['number'])
self.assertEqual(jsondetails['string'], 'string')
def test_update_and_fetch(self):
# # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
jsondetails = CourseDetails.fetch(self.course_location)
......@@ -101,8 +129,60 @@ class CourseDetailsTestCase(CourseTestCase):
jsondetails.effort, "After set effort"
)
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self):
settings_details_url = reverse(
'settings_details',
kwargs={
'org': self.course_location.org,
'name': self.course_location.name,
'course': self.course_location.course
}
)
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
response = self.client.get(settings_details_url)
self.assertContains(response, "Course Summary Page")
self.assertContains(response, "course summary page will not be viewable")
self.assertContains(response, "Course Start Date")
self.assertContains(response, "Course End Date")
self.assertNotContains(response, "Enrollment Start Date")
self.assertNotContains(response, "Enrollment End Date")
self.assertContains(response, "not the dates shown on your course summary page")
self.assertNotContains(response, "Introducing Your Course")
self.assertNotContains(response, "Requirements")
def test_regular_site_fetch(self):
settings_details_url = reverse(
'settings_details',
kwargs={
'org': self.course_location.org,
'name': self.course_location.name,
'course': self.course_location.course
}
)
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
response = self.client.get(settings_details_url)
self.assertContains(response, "Course Summary Page")
self.assertNotContains(response, "course summary page will not be viewable")
self.assertContains(response, "Course Start Date")
self.assertContains(response, "Course End Date")
self.assertContains(response, "Enrollment Start Date")
self.assertContains(response, "Enrollment End Date")
self.assertNotContains(response, "not the dates shown on your course summary page")
self.assertContains(response, "Introducing Your Course")
self.assertContains(response, "Requirements")
class CourseDetailsViewTest(CourseTestCase):
"""
Tests for modifying content on the first course settings page (course dates, overview, etc.).
"""
def alter_field(self, url, details, field, val):
setattr(details, field, val)
# Need to partially serialize payload b/c the mock doesn't handle it correctly
......@@ -116,11 +196,8 @@ class CourseDetailsViewTest(CourseTestCase):
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val))
@staticmethod
def convert_datetime_to_iso(datetime):
if datetime is not None:
return datetime.isoformat("T")
else:
return None
def convert_datetime_to_iso(dt):
return Date().to_json(dt)
def test_update_and_fetch(self):
details = CourseDetails.fetch(self.course_location)
......@@ -151,22 +228,12 @@ class CourseDetailsViewTest(CourseTestCase):
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
@staticmethod
def struct_to_datetime(struct_time):
return datetime.datetime(*struct_time[:6], tzinfo=UTC())
def compare_date_fields(self, details, encoded, context, field):
if details[field] is not None:
date = Date()
if field in encoded and encoded[field] is not None:
encoded_encoded = date.from_json(encoded[field])
dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded)
if isinstance(details[field], datetime.datetime):
dt2 = details[field]
else:
details_encoded = date.from_json(details[field])
dt2 = CourseDetailsViewTest.struct_to_datetime(details_encoded)
dt1 = date.from_json(encoded[field])
dt2 = details[field]
expected_delta = datetime.timedelta(0)
self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context)
......@@ -177,6 +244,9 @@ class CourseDetailsViewTest(CourseTestCase):
class CourseGradingTest(CourseTestCase):
"""
Tests for the course settings grading page.
"""
def test_initial_grader(self):
descriptor = get_modulestore(self.course_location).get_item(self.course_location)
test_grader = CourseGradingModel(descriptor)
......@@ -252,6 +322,9 @@ class CourseGradingTest(CourseTestCase):
class CourseMetadataEditingTest(CourseTestCase):
"""
Tests for CourseMetadata.
"""
def setUp(self):
CourseTestCase.setUp(self)
# add in the full class too
......
from contentstore.utils import get_modulestore, get_url_reverse
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
......
......@@ -4,6 +4,7 @@ import mock
import collections
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
......@@ -11,11 +12,52 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
class LMSLinksTestCase(TestCase):
""" Tests for LMS links. """
def about_page_test(self):
""" Get URL for about page. """
""" Get URL for about page, no marketing site """
# default for ENABLE_MKTG_SITE is False.
self.assertEquals(self.get_about_page_link(), "//localhost:8000/courses/mitX/101/test/about")
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def about_page_marketing_site_test(self):
""" Get URL for about page, marketing root present. """
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
self.assertEquals(self.get_about_page_link(), "//dummy-root/courses/mitX/101/test/about")
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
self.assertEquals(self.get_about_page_link(), "//localhost:8000/courses/mitX/101/test/about")
@override_settings(MKTG_URLS={'ROOT': 'http://www.dummy'})
def about_page_marketing_site_remove_http_test(self):
""" Get URL for about page, marketing root present, remove http://. """
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about")
@override_settings(MKTG_URLS={'ROOT': 'https://www.dummy'})
def about_page_marketing_site_remove_https_test(self):
""" Get URL for about page, marketing root present, remove https://. """
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about")
@override_settings(MKTG_URLS={'ROOT': 'www.dummyhttps://x'})
def about_page_marketing_site_https__edge_test(self):
""" Get URL for about page, only remove https:// at the beginning of the string. """
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
self.assertEquals(self.get_about_page_link(), "//www.dummyhttps://x/courses/mitX/101/test/about")
@override_settings(MKTG_URLS={})
def about_page_marketing_urls_not_set_test(self):
""" Error case. ENABLE_MKTG_SITE is True, but there is either no MKTG_URLS, or no MKTG_URLS Root property. """
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
self.assertEquals(self.get_about_page_link(), None)
@override_settings(LMS_BASE=None)
def about_page_no_lms_base_test(self):
""" No LMS_BASE, nor is ENABLE_MKTG_SITE True """
self.assertEquals(self.get_about_page_link(), None)
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")
link = utils.get_lms_link_for_about_page(location)
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about")
return utils.get_lms_link_for_about_page(location)
def lms_link_test(self):
""" Tests get_lms_link_for_item. """
......
......@@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse
from .utils import parse_json, user, registration
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
import datetime
from pytz import UTC
class ContentStoreTestCase(ModuleStoreTestCase):
......@@ -111,6 +115,18 @@ class AuthTestCase(ContentStoreTestCase):
# Now login should work
self.login(self.email, self.pw)
def test_login_link_on_activation_age(self):
self.create_account(self.username, self.email, self.pw)
# we want to test the rendering of the activation page when the user isn't logged in
self.client.logout()
resp = self._activate_user(self.email)
self.assertEqual(resp.status_code, 200)
# check the the HTML has links to the right login page. Note that this is merely a content
# check and thus could be fragile should the wording change on this page
expected = 'You can now <a href="' + reverse('login') + '">login</a>.'
self.assertIn(expected, resp.content)
def test_private_pages_auth(self):
"""Make sure pages that do require login work."""
auth_pages = (
......@@ -150,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase):
self.assertEqual(resp.status_code, 302)
# Logged in should work.
class ForumTestCase(CourseTestCase):
def setUp(self):
""" Creates the test course. """
super(ForumTestCase, self).setUp()
self.course = CourseFactory.create(org='testX', number='727', display_name='Forum Course')
def test_blackouts(self):
now = datetime.datetime.now(UTC)
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
[(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)),
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
self.assertTrue(self.course.forum_posts_allowed)
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
[(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)),
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
self.assertFalse(self.course.forum_posts_allowed)
......@@ -4,8 +4,11 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from django.core.urlresolvers import reverse
import copy
import logging
import re
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
log = logging.getLogger(__name__)
#In order to instantiate an open ended tab automatically, need to have this data
OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"}
......@@ -107,9 +110,29 @@ def get_lms_link_for_about_page(location):
"""
Returns the url to the course about page from the location tuple.
"""
if settings.LMS_BASE is not None:
lms_link = "//{lms_base}/courses/{course_id}/about".format(
lms_base=settings.LMS_BASE,
if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False):
if not hasattr(settings, 'MKTG_URLS'):
log.exception("ENABLE_MKTG_SITE is True, but MKTG_URLS is not defined.")
about_base = None
else:
marketing_urls = settings.MKTG_URLS
if marketing_urls.get('ROOT', None) is None:
log.exception('There is no ROOT defined in MKTG_URLS')
about_base = None
else:
# Root will be "https://www.edx.org". The complete URL will still not be exactly correct,
# but redirects exist from www.edx.org to get to the Drupal course about page URL.
about_base = marketing_urls.get('ROOT')
# Strip off https:// (or http://) to be consistent with the formatting of LMS_BASE.
about_base = re.sub(r"^https?://", "", about_base)
elif settings.LMS_BASE is not None:
about_base = settings.LMS_BASE
else:
about_base = None
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)
)
else:
......@@ -201,14 +224,14 @@ def add_extra_panel_tab(tab_type, course):
@param course: A course object from the modulestore.
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
"""
#Copy course tabs
# Copy course tabs
course_tabs = copy.copy(course.tabs)
changed = False
#Check to see if open ended panel is defined in the course
# Check to see if open ended panel is defined in the course
tab_panel = EXTRA_TAB_PANELS.get(tab_type)
if tab_panel not in course_tabs:
#Add panel to the tabs if it is not defined
# Add panel to the tabs if it is not defined
course_tabs.append(tab_panel)
changed = True
return changed, course_tabs
......@@ -221,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course):
@param course: A course object from the modulestore.
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
"""
#Copy course tabs
# Copy course tabs
course_tabs = copy.copy(course.tabs)
changed = False
#Check to see if open ended panel is defined in the course
# Check to see if open ended panel is defined in the course
tab_panel = EXTRA_TAB_PANELS.get(tab_type)
if tab_panel in course_tabs:
#Add panel to the tabs if it is not defined
# Add panel to the tabs if it is not defined
course_tabs = [ct for ct in course_tabs if ct != tab_panel]
changed = True
return changed, course_tabs
......@@ -25,6 +25,8 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent
from xmodule.util.date_utils import get_default_time_display
from xmodule.modulestore import InvalidLocationError
from xmodule.exceptions import NotFoundError
from ..utils import get_url_reverse
from .access import get_location_and_verify_access
......@@ -62,7 +64,7 @@ def asset_index(request, org, course, name):
asset_id = asset['_id']
display_info = {}
display_info['displayname'] = asset['displayname']
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple())
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'])
asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name'])
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
......@@ -78,10 +80,17 @@ def asset_index(request, org, course, name):
'active_tab': 'assets',
'context_course': course_module,
'assets': asset_display,
'upload_asset_callback_url': upload_asset_callback_url
'upload_asset_callback_url': upload_asset_callback_url,
'remove_asset_callback_url': reverse('remove_asset', kwargs={
'org': org,
'course': course,
'name': name
})
})
@login_required
@ensure_csrf_cookie
def upload_asset(request, org, course, coursename):
'''
cdodge: this method allows for POST uploading of files into the course asset library, which will
......@@ -103,6 +112,9 @@ def upload_asset(request, org, course, coursename):
logging.error('Could not find course' + location)
return HttpResponseBadRequest()
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
......@@ -131,7 +143,7 @@ def upload_asset(request, org, course, coursename):
readback = contentstore().find(content.location)
response_payload = {'displayname': content.name,
'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()),
'uploadDate': get_default_time_display(readback.last_modified_at),
'url': StaticContent.get_url_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'
......@@ -144,6 +156,57 @@ def upload_asset(request, org, course, coursename):
@ensure_csrf_cookie
@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
'''
get_location_and_verify_access(request, org, course, name)
location = request.POST['location']
# make sure the location is valid
try:
loc = StaticContent.get_location_from_path(location)
except InvalidLocationError:
# return a 'Bad Request' to browser as we have a malformed Location
response = HttpResponse()
response.status_code = 400
return response
# also make sure the item to delete actually exists
try:
content = contentstore().find(loc)
except NotFoundError:
response = HttpResponse()
response.status_code = 404
return response
# ok, save the content into the trashcan
contentstore('trashcan').save(content)
# see if there is a thumbnail as well, if so move that as well
if content.thumbnail_location is not None:
try:
thumbnail_content = contentstore().find(content.thumbnail_location)
contentstore('trashcan').save(thumbnail_content)
# hard delete thumbnail from origin
contentstore().delete(thumbnail_content.get_id())
# remove from any caching
del_cached_content(thumbnail_content.location)
except:
pass # OK if this is left dangling
# delete the original
contentstore().delete(content.get_id())
# remove from cache
del_cached_content(content.location)
return HttpResponse()
@ensure_csrf_cookie
@login_required
def import_course(request, org, course, name):
location = get_location_and_verify_access(request, org, course, name)
......@@ -227,11 +290,9 @@ def generate_export_course(request, org, course, name):
root_dir = path(mkdtemp())
# export out to a tempdir
logging.debug('root = {0}'.format(root_dir))
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
#filename = root_dir / name + '.tar.gz'
logging.debug('tar file being generated at {0}'.format(export_file.name))
tar_file = tarfile.open(name=export_file.name, mode='w:gz')
......
......@@ -7,7 +7,7 @@ from django.contrib.auth.decorators import login_required
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 mitxmako.shortcuts import render_to_response
from xmodule.modulestore import Location
......@@ -42,7 +42,7 @@ COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
NOTE_COMPONENT_TYPES = ['notes']
ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud'] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud', 'videoalpha'] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
ADVANCED_COMPONENT_CATEGORY = 'advanced'
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
......@@ -50,11 +50,18 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
@login_required
def edit_subsection(request, location):
# check that we have permissions to edit this item
course = get_course_for_item(location)
try:
course = get_course_for_item(location)
except InvalidLocationError:
return HttpResponseBadRequest()
if not has_access(request.user, course.location):
raise PermissionDenied()
item = modulestore().get_item(location, depth=1)
try:
item = modulestore().get_item(location, depth=1)
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)
......@@ -113,11 +120,18 @@ def edit_unit(request, location):
id: A Location URL
"""
course = get_course_for_item(location)
try:
course = get_course_for_item(location)
except InvalidLocationError:
return HttpResponseBadRequest()
if not has_access(request.user, course.location):
raise PermissionDenied()
item = modulestore().get_item(location, depth=1)
try:
item = modulestore().get_item(location, depth=1)
except ItemNotFoundError:
return HttpResponseBadRequest()
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
......@@ -149,8 +163,7 @@ def edit_unit(request, location):
component_templates[category].append((
template.display_name_with_default,
template.location.url(),
hasattr(template, 'markdown') and template.markdown is not None,
template.cms.empty,
hasattr(template, 'markdown') and template.markdown is not None
))
components = [
......
......@@ -2,7 +2,6 @@
Views related to operations on course objects
"""
import json
import time
from django.contrib.auth.decorators import login_required
from django_future.csrf import ensure_csrf_cookie
......@@ -13,8 +12,8 @@ from django.core.urlresolvers import reverse
from mitxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.modulestore.exceptions import ItemNotFoundError, \
InvalidLocationError
from xmodule.modulestore import Location
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
......@@ -32,9 +31,8 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \
NOTE_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY
from django_comment_common.utils import seed_permissions_roles
# TODO: should explicitly enumerate exports with __all__
import datetime
from django.utils.timezone import UTC
__all__ = ['course_index', 'create_new_course', 'course_info',
'course_info_updates', 'get_course_settings',
'course_config_graders_page',
......@@ -130,7 +128,7 @@ def create_new_course(request):
new_course.display_name = display_name
# set a default start date to now
new_course.start = time.gmtime()
new_course.start = datetime.datetime.now(UTC())
initialize_course_tabs(new_course)
......@@ -229,7 +227,8 @@ def get_course_settings(request, org, course, name):
kwargs={"org": org,
"course": course,
"name": name,
"section": "details"})
"section": "details"}),
'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False)
})
......@@ -357,52 +356,55 @@ def course_advanced_updates(request, org, course, name):
# 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
# 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
# Get the course so that we can scrape current tabs
course_module = modulestore().get_item(location)
#Maps tab types to components
# Maps tab types to components
tab_component_map = {
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
'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
# Add tab to the course if needed
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
#If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
# If 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.
# Set this flag to avoid the tab removal code below.
found_ac_type = True
break
#If we did not find a module type in the advanced settings,
# If we did not find a module type in the advanced settings,
# we may need to remove the tab from the course.
if not found_ac_type:
#Remove tab from the course if needed
# Remove tab from the course if needed
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
if changed:
course_module.tabs = new_tabs
request_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
response_json = json.dumps(CourseMetadata.update_from_json(location,
try:
response_json = json.dumps(CourseMetadata.update_from_json(location,
request_body,
filter_tabs=filter_tabs))
except (TypeError, ValueError), e:
return HttpResponseBadRequest("Incorrect setting format. " + str(e), content_type="text/plain")
return HttpResponse(response_json, mimetype="application/json")
......@@ -103,7 +103,7 @@ def clone_item(request):
@expect_json
def delete_item(request):
item_location = request.POST['id']
item_loc = Location(item_location)
item_location = Location(item_location)
# check permissions for this user within this course
if not has_access(request.user, item_location):
......@@ -124,11 +124,11 @@ def delete_item(request):
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
if delete_all_versions:
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
parent_locs = modulestore('direct').get_parent_locations(item_location, None)
for parent_loc in parent_locs:
parent = modulestore('direct').get_item(parent_loc)
item_url = item_loc.url()
item_url = item_location.url()
if item_url in parent.children:
children = parent.children
children.remove(item_url)
......
......@@ -3,26 +3,26 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata
import json
from json.encoder import JSONEncoder
import time
from contentstore.utils import get_modulestore
from models.settings import course_grading
from contentstore.utils import update_item
from xmodule.fields import Date
import re
import logging
import datetime
class CourseDetails(object):
def __init__(self, location):
self.course_location = location # a Location obj
self.course_location = location # a Location obj
self.start_date = None # 'start'
self.end_date = None # 'end'
self.end_date = None # 'end'
self.enrollment_start = None
self.enrollment_end = None
self.syllabus = None # a pdf file asset
self.overview = "" # html to render as the overview
self.intro_video = None # a video pointer
self.effort = None # int hours/week
self.syllabus = None # a pdf file asset
self.overview = "" # html to render as the overview
self.intro_video = None # a video pointer
self.effort = None # int hours/week
@classmethod
def fetch(cls, course_location):
......@@ -41,25 +41,25 @@ class CourseDetails(object):
course.enrollment_start = descriptor.enrollment_start
course.enrollment_end = descriptor.enrollment_end
temploc = course_location._replace(category='about', name='syllabus')
temploc = course_location.replace(category='about', name='syllabus')
try:
course.syllabus = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='overview')
temploc = temploc.replace(name='overview')
try:
course.overview = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='effort')
temploc = temploc.replace(name='effort')
try:
course.effort = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='video')
temploc = temploc.replace(name='video')
try:
raw_video = get_modulestore(temploc).get_item(temploc).data
course.intro_video = CourseDetails.parse_video_tag(raw_video)
......@@ -73,9 +73,9 @@ class CourseDetails(object):
"""
Decode the json into CourseDetails and save any changed attrs to the db
"""
## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
# TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
course_location = jsondict['course_location']
## Will probably want to cache the inflight courses because every blur generates an update
# Will probably want to cache the inflight courses because every blur generates an update
descriptor = get_modulestore(course_location).get_item(course_location)
dirty = False
......@@ -126,16 +126,16 @@ class CourseDetails(object):
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
# to make faster, could compare against db or could have client send over a list of which fields changed.
temploc = Location(course_location)._replace(category='about', name='syllabus')
temploc = Location(course_location).replace(category='about', name='syllabus')
update_item(temploc, jsondict['syllabus'])
temploc = temploc._replace(name='overview')
temploc = temploc.replace(name='overview')
update_item(temploc, jsondict['overview'])
temploc = temploc._replace(name='effort')
temploc = temploc.replace(name='effort')
update_item(temploc, jsondict['effort'])
temploc = temploc._replace(name='video')
temploc = temploc.replace(name='video')
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
update_item(temploc, recomposed_video_tag)
......@@ -153,9 +153,9 @@ class CourseDetails(object):
if not raw_video:
return None
keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
keystring_matcher = re.search(r'(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher is None:
keystring_matcher = re.search('<?=\d+:[a-zA-Z0-9_-]+', raw_video)
keystring_matcher = re.search(r'<?=\d+:[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher:
return keystring_matcher.group(0)
......@@ -174,14 +174,14 @@ class CourseDetails(object):
return result
# TODO move to a more general util? Is there a better way to do the isinstance model check?
# TODO move to a more general util?
class CourseSettingsEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel):
if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)):
return obj.__dict__
elif isinstance(obj, Location):
return obj.dict()
elif isinstance(obj, time.struct_time):
elif isinstance(obj, datetime.datetime):
return Date().to_json(obj)
else:
return JSONEncoder.default(self, obj)
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore.inheritance import own_metadata
from xblock.core import Scope
from xmodule.course_module import CourseDescriptor
......
......@@ -23,7 +23,7 @@ MODULESTORE_OPTIONS = {
'db': 'test_xmodule',
'collection': 'acceptance_modulestore',
'fs_root': TEST_ROOT / "data",
'render_template': 'mitxmako.shortcuts.render_to_string',
'render_template': 'mitxmako.shortcuts.render_to_string'
}
MODULESTORE = {
......
......@@ -91,11 +91,19 @@ CACHES = ENV_TOKENS['CACHES']
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
# allow for environments to specify what cookie name our login subsystem should use
# this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can
# happen with some browsers (e.g. Firefox)
if ENV_TOKENS.get('SESSION_COOKIE_NAME', None):
# NOTE, there's a bug in Django (http://bugs.python.org/issue18012) which necessitates this being a str()
SESSION_COOKIE_NAME = str(ENV_TOKENS.get('SESSION_COOKIE_NAME'))
#Email overrides
DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL)
DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL)
ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
#Timezone overrides
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
......@@ -104,9 +112,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
MITX_FEATURES[feature] = value
# load segment.io key, provide a dummy if it does not exist
SEGMENT_IO_KEY = ENV_TOKENS.get('SEGMENT_IO_KEY', '***REMOVED***')
LOGGING = get_logger_config(LOG_DIR,
logging_env=ENV_TOKENS['LOGGING_ENV'],
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
......@@ -118,6 +123,13 @@ LOGGING = get_logger_config(LOG_DIR,
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
AUTH_TOKENS = json.load(auth_file)
# If Segment.io key specified, load it and turn on Segment.io if the feature flag is set
# Note that this is the Studio key. There is a separate key for the LMS.
SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY')
if SEGMENT_IO_KEY:
MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False)
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
DATABASES = AUTH_TOKENS['DATABASES']
......
......@@ -21,26 +21,40 @@ Longer TODO:
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
# pylint: disable=W0401, W0611, W0614
import sys
import lms.envs.common
from lms.envs.common import USE_TZ
from path import path
############################ FEATURE CONFIGURATION #############################
MITX_FEATURES = {
'USE_DJANGO_PIPELINE': True,
'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False,
'AUTH_USE_MIT_CERTIFICATES': False,
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
'STAFF_EMAIL': '', # email address for staff (eg to request course creation)
# do not display video when running automated acceptance tests
'STUB_VIDEO_FOR_TESTING': False,
# email address for staff (eg to request course creation)
'STAFF_EMAIL': '',
'STUDIO_NPS_SURVEY': True,
'SEGMENT_IO': True,
# Segment.io - must explicitly turn it on for production
'SEGMENT_IO': False,
# Enable URL that shows information about the status of various services
'ENABLE_SERVICE_STATUS': False
'ENABLE_SERVICE_STATUS': False,
# Don't autoplay videos for course authors
'AUTOPLAY_VIDEOS': False
}
ENABLE_JASMINE = False
......@@ -180,7 +194,7 @@ STATICFILES_DIRS = [
# Locale/Internationalization
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
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
USE_I18N = True
USE_L10N = True
......@@ -221,9 +235,10 @@ PIPELINE_JS = {
'source_filenames': sorted(
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') +
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js')
) + ['js/hesitate.js', 'js/base.js',
'js/models/feedback.js', 'js/views/feedback.js',
'js/models/section.js', 'js/views/section.js'],
) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js',
'js/models/section.js', 'js/views/section.js',
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
'js/views/assets.js'],
'output_filename': 'js/cms-application.js',
'test_order': 0
},
......@@ -319,6 +334,7 @@ INSTALLED_APPS = (
'track',
# For asset pipelining
'mitxmako',
'pipeline',
'staticfiles',
'static_replace',
......@@ -330,3 +346,14 @@ INSTALLED_APPS = (
################# EDX MARKETING SITE ##################################
EDXMKTG_COOKIE_NAME = 'edxloggedin'
MKTG_URLS = {}
MKTG_URL_LINK_MAP = {
'ABOUT': 'about_edx',
'CONTACT': 'contact',
'FAQ': 'help_edx',
'COURSES': 'courses',
'ROOT': 'root',
'TOS': 'tos',
'HONOR': 'honor',
'PRIVACY': 'privacy_edx',
}
......@@ -22,7 +22,7 @@ modulestore_options = {
'db': 'xmodule',
'collection': 'modulestore',
'fs_root': GITHUB_REPO_ROOT,
'render_template': 'mitxmako.shortcuts.render_to_string',
'render_template': 'mitxmako.shortcuts.render_to_string'
}
MODULESTORE = {
......@@ -43,10 +43,15 @@ CONTENTSTORE = {
'OPTIONS': {
'host': 'localhost',
'db': 'xcontent',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
}
}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
......@@ -64,7 +69,7 @@ REPOS = {
},
'content-mit-6002x': {
'branch': 'master',
#'origin': 'git@github.com:MITx/6002x-fall-2012.git',
# 'origin': 'git@github.com:MITx/6002x-fall-2012.git',
'origin': 'git@github.com:MITx/content-mit-6002x.git',
},
'6.00x': {
......@@ -163,5 +168,19 @@ MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
# Enable URL that shows information about the status of variuous services
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
# segment-io key for dev
SEGMENT_IO_KEY = 'mty8edrrsg'
############################# SEGMENT-IO ##################################
# If there's an environment variable set, grab it and turn on Segment.io
# Note that this is the Studio key. There is a separate key for the LMS.
import os
SEGMENT_IO_KEY = os.environ.get('SEGMENT_IO_KEY')
if SEGMENT_IO_KEY:
MITX_FEATURES['SEGMENT_IO'] = True
#####################################################################
# Lastly, see if the developer has any local overrides.
try:
from .private import *
except ImportError:
pass
......@@ -7,9 +7,7 @@
# FORCE_SCRIPT_NAME = '/cms'
from .common import *
from logsettings import get_logger_config
from .dev import *
import socket
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
......
......@@ -48,7 +48,7 @@ MODULESTORE_OPTIONS = {
'db': 'test_xmodule',
'collection': 'test_modulestore',
'fs_root': TEST_ROOT / "data",
'render_template': 'mitxmako.shortcuts.render_to_string',
'render_template': 'mitxmako.shortcuts.render_to_string'
}
MODULESTORE = {
......@@ -70,7 +70,13 @@ CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db': 'xcontent',
'db': 'test_xmodule',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
}
}
......@@ -121,7 +127,7 @@ CELERY_RESULT_BACKEND = 'cache'
BROKER_TRANSPORT = 'memory'
################### Make tests faster
#http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
# http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
PASSWORD_HASHERS = (
'django.contrib.auth.hashers.SHA1PasswordHasher',
'django.contrib.auth.hashers.MD5PasswordHasher',
......
'''
Used for pydev eclipse. Should be innocuous for everyone else.
Created on May 8, 2013
@author: dmitchell
'''
#!/home/<username>/mitx_all/python/bin/python
from django.core import management
if __name__ == '__main__':
management.execute_from_command_line()
......@@ -7,6 +7,7 @@
"js/vendor/jquery.cookie.js",
"js/vendor/json2.js",
"js/vendor/underscore-min.js",
"js/vendor/underscore.string.min.js",
"js/vendor/backbone-min.js",
"js/vendor/jquery.leanModal.min.js",
"js/vendor/sinon-1.7.1.js",
......
../../../templates/js/metadata-editor.underscore
\ No newline at end of file
../../../templates/js/metadata-number-entry.underscore
\ No newline at end of file
../../../templates/js/metadata-option-entry.underscore
\ No newline at end of file
../../../templates/js/metadata-string-entry.underscore
\ No newline at end of file
describe "CMS.Models.SystemFeedback", ->
beforeEach ->
@model = new CMS.Models.SystemFeedback()
it "should have an empty message by default", ->
expect(@model.get("message")).toEqual("")
it "should have an empty title by default", ->
expect(@model.get("title")).toEqual("")
it "should not have an intent set by default", ->
expect(@model.get("intent")).toBeNull()
describe "CMS.Models.WarningMessage", ->
beforeEach ->
@model = new CMS.Models.WarningMessage()
it "should have the correct intent", ->
expect(@model.get("intent")).toEqual("warning")
describe "CMS.Models.ErrorMessage", ->
beforeEach ->
@model = new CMS.Models.ErrorMessage()
it "should have the correct intent", ->
expect(@model.get("intent")).toEqual("error")
describe "CMS.Models.ConfirmationMessage", ->
beforeEach ->
@model = new CMS.Models.ConfirmationMessage()
it "should have the correct intent", ->
expect(@model.get("intent")).toEqual("confirmation")
describe "CMS.Models.Metadata", ->
it "knows when the value has not been modified", ->
model = new CMS.Models.Metadata(
{'value': 'original', 'explicitly_set': false})
expect(model.isModified()).toBeFalsy()
model = new CMS.Models.Metadata(
{'value': 'original', 'explicitly_set': true})
model.setValue('original')
expect(model.isModified()).toBeFalsy()
it "knows when the value has been modified", ->
model = new CMS.Models.Metadata(
{'value': 'original', 'explicitly_set': false})
model.setValue('original')
expect(model.isModified()).toBeTruthy()
model = new CMS.Models.Metadata(
{'value': 'original', 'explicitly_set': true})
model.setValue('modified')
expect(model.isModified()).toBeTruthy()
it "tracks when values have been explicitly set", ->
model = new CMS.Models.Metadata(
{'value': 'original', 'explicitly_set': false})
expect(model.isExplicitlySet()).toBeFalsy()
model.setValue('original')
expect(model.isExplicitlySet()).toBeTruthy()
it "has both 'display value' and a 'value' methods", ->
model = new CMS.Models.Metadata(
{'value': 'default', 'explicitly_set': false})
expect(model.getValue()).toBeNull
expect(model.getDisplayValue()).toBe('default')
model.setValue('modified')
expect(model.getValue()).toBe('modified')
expect(model.getDisplayValue()).toBe('modified')
it "has a clear method for reverting to the default", ->
model = new CMS.Models.Metadata(
{'value': 'original', 'default_value' : 'default', 'explicitly_set': true})
model.clear()
expect(model.getValue()).toBeNull
expect(model.getDisplayValue()).toBe('default')
expect(model.isExplicitlySet()).toBeFalsy()
it "has a getter for field name", ->
model = new CMS.Models.Metadata({'field_name': 'foo'})
expect(model.getFieldName()).toBe('foo')
it "has a getter for options", ->
model = new CMS.Models.Metadata({'options': ['foo', 'bar']})
expect(model.getOptions()).toEqual(['foo', 'bar'])
it "has a getter for type", ->
model = new CMS.Models.Metadata({'type': 'Integer'})
expect(model.getType()).toBe(CMS.Models.Metadata.INTEGER_TYPE)
......@@ -18,79 +18,105 @@ beforeEach ->
else
return trimmedText.indexOf(text) != -1;
describe "CMS.Views.Alert as base class", ->
describe "CMS.Views.SystemFeedback", ->
beforeEach ->
@model = new CMS.Models.ConfirmationMessage({
@options =
title: "Portal"
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
})
# it will be interesting to see when this.render is called, so lets spy on it
spyOn(CMS.Views.Alert.prototype, 'render').andCallThrough()
it "renders on initalize", ->
view = new CMS.Views.Alert({model: @model})
expect(view.render).toHaveBeenCalled()
@renderSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'render').andCallThrough()
@showSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'show').andCallThrough()
@hideSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'hide').andCallThrough()
it "requires a type and an intent", ->
neither = =>
new CMS.Views.SystemFeedback(@options)
noType = =>
options = $.extend({}, @options)
options.intent = "confirmation"
new CMS.Views.SystemFeedback(options)
noIntent = =>
options = $.extend({}, @options)
options.type = "alert"
new CMS.Views.SystemFeedback(options)
both = =>
options = $.extend({}, @options)
options.type = "alert"
options.intent = "confirmation"
new CMS.Views.SystemFeedback(options)
expect(neither).toThrow()
expect(noType).toThrow()
expect(noIntent).toThrow()
expect(both).not.toThrow()
# for simplicity, we'll use CMS.Views.Alert.Confirmation from here on,
# which extends and proxies to CMS.Views.SystemFeedback
it "does not show on initalize", ->
view = new CMS.Views.Alert.Confirmation(@options)
expect(@renderSpy).not.toHaveBeenCalled()
expect(@showSpy).not.toHaveBeenCalled()
it "renders the template", ->
view = new CMS.Views.Alert({model: @model})
view = new CMS.Views.Alert.Confirmation(@options)
view.show()
expect(view.$(".action-close")).toBeDefined()
expect(view.$('.wrapper')).toBeShown()
expect(view.$el).toContainText(@model.get("title"))
expect(view.$el).toContainText(@model.get("message"))
expect(view.$el).toContainText(@options.title)
expect(view.$el).toContainText(@options.message)
it "close button sends a .hide() message", ->
spyOn(CMS.Views.Alert.prototype, 'hide').andCallThrough()
view = new CMS.Views.Alert({model: @model})
view = new CMS.Views.Alert.Confirmation(@options).show()
view.$(".action-close").click()
expect(CMS.Views.Alert.prototype.hide).toHaveBeenCalled()
expect(@hideSpy).toHaveBeenCalled()
expect(view.$('.wrapper')).toBeHiding()
describe "CMS.Views.Prompt", ->
beforeEach ->
@model = new CMS.Models.ConfirmationMessage({
title: "Portal"
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
})
# for some reason, expect($("body")) blows up the test runner, so this test
# just exercises the Prompt rather than asserting on anything. Best I can
# do for now. :(
it "changes class on body", ->
# expect($("body")).not.toHaveClass("prompt-is-shown")
view = new CMS.Views.Prompt({model: @model})
view = new CMS.Views.Prompt.Confirmation({
title: "Portal"
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
})
# expect($("body")).toHaveClass("prompt-is-shown")
view.hide()
# expect($("body")).not.toHaveClass("prompt-is-shown")
describe "CMS.Views.Alert click events", ->
describe "CMS.Views.SystemFeedback click events", ->
beforeEach ->
@model = new CMS.Models.WarningMessage(
@primaryClickSpy = jasmine.createSpy('primaryClick')
@secondaryClickSpy = jasmine.createSpy('secondaryClick')
@view = new CMS.Views.Notification.Warning(
title: "Unsaved",
message: "Your content is currently Unsaved.",
actions:
primary:
text: "Save",
class: "save-button",
click: jasmine.createSpy('primaryClick')
click: @primaryClickSpy
secondary: [{
text: "Revert",
class: "cancel-button",
click: jasmine.createSpy('secondaryClick')
click: @secondaryClickSpy
}]
)
@view = new CMS.Views.Alert({model: @model})
@view.show()
it "should trigger the primary event on a primary click", ->
@view.primaryClick()
expect(@model.get('actions').primary.click).toHaveBeenCalled()
@view.$(".action-primary").click()
expect(@primaryClickSpy).toHaveBeenCalled()
expect(@secondaryClickSpy).not.toHaveBeenCalled()
it "should trigger the secondary event on a secondary click", ->
@view.secondaryClick()
expect(@model.get('actions').secondary[0].click).toHaveBeenCalled()
@view.$(".action-secondary").click()
expect(@secondaryClickSpy).toHaveBeenCalled()
expect(@primaryClickSpy).not.toHaveBeenCalled()
it "should apply class to primary action", ->
expect(@view.$(".action-primary")).toHaveClass("save-button")
......@@ -100,20 +126,18 @@ describe "CMS.Views.Alert click events", ->
describe "CMS.Views.Notification minShown and maxShown", ->
beforeEach ->
@model = new CMS.Models.SystemFeedback(
intent: "saving"
title: "Saving"
)
spyOn(CMS.Views.Notification.prototype, 'show').andCallThrough()
spyOn(CMS.Views.Notification.prototype, 'hide').andCallThrough()
@showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show')
@showSpy.andCallThrough()
@hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide')
@hideSpy.andCallThrough()
@clock = sinon.useFakeTimers()
afterEach ->
@clock.restore()
it "a minShown view should not hide too quickly", ->
view = new CMS.Views.Notification({model: @model, minShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
view = new CMS.Views.Notification.Saving({minShown: 1000})
view.show()
expect(view.$('.wrapper')).toBeShown()
# call hide() on it, but the minShown should prevent it from hiding right away
......@@ -125,8 +149,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding()
it "a maxShown view should hide by itself", ->
view = new CMS.Views.Notification({model: @model, maxShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
view = new CMS.Views.Notification.Saving({maxShown: 1000})
view.show()
expect(view.$('.wrapper')).toBeShown()
# wait for the maxShown timeout to expire, and check again
......@@ -134,13 +158,13 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding()
it "a minShown view can stay visible longer", ->
view = new CMS.Views.Notification({model: @model, minShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
view = new CMS.Views.Notification.Saving({minShown: 1000})
view.show()
expect(view.$('.wrapper')).toBeShown()
# wait for the minShown timeout to expire, and check again
@clock.tick(1001)
expect(CMS.Views.Notification.prototype.hide).not.toHaveBeenCalled()
expect(@hideSpy).not.toHaveBeenCalled()
expect(view.$('.wrapper')).toBeShown()
# can now hide immediately
......@@ -148,8 +172,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding()
it "a maxShown view can hide early", ->
view = new CMS.Views.Notification({model: @model, maxShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
view = new CMS.Views.Notification.Saving({maxShown: 1000})
view.show()
expect(view.$('.wrapper')).toBeShown()
# wait 50 milliseconds, and hide it early
......@@ -162,7 +186,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding()
it "a view can have both maxShown and minShown", ->
view = new CMS.Views.Notification({model: @model, minShown: 1000, maxShown: 2000})
view = new CMS.Views.Notification.Saving({minShown: 1000, maxShown: 2000})
view.show()
# can't hide early
@clock.tick(50)
......
......@@ -73,13 +73,3 @@ describe "CMS.Views.ModuleEdit", ->
expect(XModule.loadModule).toHaveBeenCalled()
expect(XModule.loadModule.mostRecentCall.args[0]).toBe($('.xmodule_display'))
describe "changedMetadata", ->
it "returns empty if no metadata loaded", ->
expect(@moduleEdit.changedMetadata()).toEqual({})
it "returns only changed values", ->
@moduleEdit.originalMetadata = {'foo', 'bar'}
spyOn(@moduleEdit, 'metadata').andReturn({'a': '', 'b': 'before', 'c': ''})
@moduleEdit.loadEdit()
@moduleEdit.metadata.andReturn({'a': '', 'b': 'after', 'd': 'only_after'})
expect(@moduleEdit.changedMetadata()).toEqual({'b' : 'after', 'd' : 'only_after'})
......@@ -18,11 +18,15 @@ $ ->
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
if ajaxSettings.notifyOnError is false
return
msg = new CMS.Models.ErrorMessage(
if jqXHR.responseText
message = _.str.truncate(jqXHR.responseText, 300)
else
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
msg = new CMS.Views.Notification.Error(
"title": gettext("Studio's having trouble saving your work")
"message": jqXHR.responseText || gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
"message": message
)
new CMS.Views.Notification({model: msg})
msg.show()
window.onTouchBasedDevice = ->
navigator.userAgent.match /iPhone|iPod|iPad/i
......
class CMS.Views.ModuleEdit extends Backbone.View
tagName: 'li'
className: 'component'
editorMode: 'editor-mode'
events:
"click .component-editor .cancel-button": 'clickCancelButton'
"click .component-editor .save-button": 'clickSaveButton'
"click .component-actions .edit-button": 'clickEditButton'
"click .component-actions .delete-button": 'onDelete'
"click .mode a": 'clickModeButton'
initialize: ->
@onDelete = @options.onDelete
......@@ -20,29 +22,39 @@ class CMS.Views.ModuleEdit extends Backbone.View
loadEdit: ->
if not @module
@module = XModule.loadModule(@$el.find('.xmodule_edit'))
@originalMetadata = @metadata()
metadata: ->
# cdodge: package up metadata which is separated into a number of input fields
# there's probably a better way to do this, but at least this lets me continue to move onwards
_metadata = {}
$metadata = @$component_editor().find('.metadata_edit')
if $metadata
# walk through the set of elments which have the 'xmetadata_name' attribute and
# build up a object to pass back to the server on the subsequent POST
_metadata[$(el).data("metadata-name")] = el.value for el in $('[data-metadata-name]', $metadata)
return _metadata
# At this point, metadata-edit.html will be loaded, and the metadata (as JSON) is available.
metadataEditor = @$el.find('.metadata_edit')
metadataData = metadataEditor.data('metadata')
models = [];
for key of metadataData
models.push(metadataData[key])
@metadataEditor = new CMS.Views.Metadata.Editor({
el: metadataEditor,
collection: new CMS.Models.MetadataCollection(models)
})
# Need to update set "active" class on data editor if there is one.
# If we are only showing settings, hide the data editor controls and update settings accordingly.
if @hasDataEditor()
@selectMode(@editorMode)
else
@hideDataEditor()
title = interpolate(gettext('<em>Editing:</em> %s'),
[@metadataEditor.getDisplayName()])
@$el.find('.component-name').html(title)
customMetadata: ->
# Hack to support metadata fields that aren't part of the metadata editor (ie, LaTeX high level source).
# Walk through the set of elements which have the 'data-metadata_name' attribute and
# build up an object to pass back to the server on the subsequent POST.
# Note that these values will always be sent back on POST, even if they did not actually change.
_metadata = {}
_metadata[$(el).data("metadata-name")] = el.value for el in $('[data-metadata-name]', @$component_editor())
return _metadata
changedMetadata: ->
currentMetadata = @metadata()
changedMetadata = {}
for key of currentMetadata
if currentMetadata[key] != @originalMetadata[key]
changedMetadata[key] = currentMetadata[key]
return changedMetadata
return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata())
cloneTemplate: (parent, template) ->
$.post("/clone_item", {
......@@ -77,7 +89,7 @@ class CMS.Views.ModuleEdit extends Backbone.View
@render()
@$el.removeClass('editing')
).fail( ->
showToastMessage("There was an error saving your changes. Please try again.", null, 3)
showToastMessage(gettext("There was an error saving your changes. Please try again."), null, 3)
)
clickCancelButton: (event) ->
......@@ -96,3 +108,38 @@ class CMS.Views.ModuleEdit extends Backbone.View
$modalCover.show().addClass('is-fixed')
@$component_editor().slideDown(150)
@loadEdit()
clickModeButton: (event) ->
event.preventDefault()
if not @hasDataEditor()
return
@selectMode(event.currentTarget.parentElement.id)
hasDataEditor: =>
return @$el.find('.wrapper-comp-editor').length > 0
selectMode: (mode) =>
dataEditor = @$el.find('.wrapper-comp-editor')
settingsEditor = @$el.find('.wrapper-comp-settings')
editorModeButton = @$el.find('#editor-mode').find("a")
settingsModeButton = @$el.find('#settings-mode').find("a")
if mode == @editorMode
# Because of CodeMirror editor, cannot hide the data editor when it is first loaded. Therefore
# we have to use a class of is-inactive instead of is-active.
dataEditor.removeClass('is-inactive')
editorModeButton.addClass('is-set')
settingsEditor.removeClass('is-active')
settingsModeButton.removeClass('is-set')
else
dataEditor.addClass('is-inactive')
editorModeButton.removeClass('is-set')
settingsEditor.addClass('is-active')
settingsModeButton.addClass('is-set')
hideDataEditor: =>
editorModeButtonParent = @$el.find('#editor-mode')
editorModeButtonParent.addClass('inactive-mode')
editorModeButtonParent.removeClass('active-mode')
@$el.find('.wrapper-comp-settings').addClass('is-active')
@$el.find('#settings-mode').find("a").addClass('is-set')
class CMS.Views.UnitEdit extends Backbone.View
events:
'click .new-component .new-component-type a': 'showComponentTemplates'
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates'
'click .new-component .new-component-type a.single-template': 'saveNewComponent'
'click .new-component .cancel-button': 'closeNewComponent'
'click .new-component-templates .new-component-template a': 'saveNewComponent'
'click .new-component-templates .cancel-button': 'closeNewComponent'
......
cms/static/img/large-advanced-icon.png

342 Bytes | W: | H:

cms/static/img/large-advanced-icon.png

633 Bytes | W: | H:

cms/static/img/large-advanced-icon.png
cms/static/img/large-advanced-icon.png
cms/static/img/large-advanced-icon.png
cms/static/img/large-advanced-icon.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -25,15 +25,12 @@ $(document).ready(function() {
$newComponentTemplatePickers = $('.new-component-templates');
$newComponentButton = $('.new-component-button');
$spinner = $('<span class="spinner-in-field-icon"></span>');
$body.bind('keyup', onKeyUp);
$('.expand-collapse-icon').bind('click', toggleSubmodules);
$('.visibility-options').bind('change', setVisibility);
$modal.bind('click', hideModal);
$modalCover.bind('click', hideModal);
$('.uploads .upload-button').bind('click', showUploadModal);
$('.upload-modal .close-button').bind('click', hideModal);
$body.on('click', '.embeddable-xml-input', function() {
$(this).select();
......@@ -145,8 +142,6 @@ $(document).ready(function() {
$('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate);
$('.edit-section-start-save').bind('click', saveSetSectionScheduleDate);
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
$body.on('click', '.section-published-date .edit-button', editSectionPublishDate);
$body.on('click', '.section-published-date .schedule-button', editSectionPublishDate);
$body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate);
......@@ -398,69 +393,6 @@ function _deleteItem($el) {
});
}
function showUploadModal(e) {
e.preventDefault();
$modal = $('.upload-modal').show();
$('.file-input').bind('change', startUpload);
$modalCover.show();
}
function showFileSelectionMenu(e) {
e.preventDefault();
$('.file-input').click();
}
function startUpload(e) {
$('.upload-modal h1').html(gettext('Uploading…'));
$('.upload-modal .file-name').html($('.file-input').val().replace('C:\\fakepath\\', ''));
$('.upload-modal .file-chooser').ajaxSubmit({
beforeSend: resetUploadBar,
uploadProgress: showUploadFeedback,
complete: displayFinishedUpload
});
$('.upload-modal .choose-file-button').hide();
$('.upload-modal .progress-bar').removeClass('loaded').show();
}
function resetUploadBar() {
var percentVal = '0%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
}
function showUploadFeedback(event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
}
function displayFinishedUpload(xhr) {
if (xhr.status = 200) {
markAsLoaded();
}
var resp = JSON.parse(xhr.responseText);
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
$('.upload-modal .embeddable').show();
$('.upload-modal .file-name').hide();
$('.upload-modal .progress-fill').html(resp.msg);
$('.upload-modal .choose-file-button').html(gettext('Load Another File')).show();
$('.upload-modal .progress-fill').width('100%');
// see if this id already exists, if so, then user must have updated an existing piece of content
$("tr[data-id='" + resp.url + "']").remove();
var template = $('#new-asset-element').html();
var html = Mustache.to_html(template, resp);
$('table > tbody').prepend(html);
analytics.track('Uploaded a File', {
'course': course_location_analytics,
'asset_url': resp.url
});
}
function markAsLoaded() {
$('.upload-modal .copy-button').css('display', 'inline-block');
$('.upload-modal .progress-bar').addClass('loaded');
......@@ -480,12 +412,6 @@ function hideModal(e) {
}
}
function onKeyUp(e) {
if (e.which == 87) {
$body.toggleClass('show-wip hide-wip');
}
}
function toggleSock(e) {
e.preventDefault();
......
CMS.Models.SystemFeedback = Backbone.Model.extend({
defaults: {
"intent": null, // "warning", "confirmation", "error", "announcement", "step-required", etc
"title": "",
"message": ""
/* could also have an "actions" hash: here is an example demonstrating
the expected structure
"actions": {
"primary": {
"text": "Save",
"class": "action-save",
"click": function() {
// do something when Save is clicked
// `this` refers to the model
}
},
"secondary": [
{
"text": "Cancel",
"class": "action-cancel",
"click": function() {}
}, {
"text": "Discard Changes",
"class": "action-discard",
"click": function() {}
}
]
}
*/
}
});
CMS.Models.WarningMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "warning"
})
});
CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "error"
})
});
CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "confirmation"
})
});
/**
* Model used for metadata setting editors. This model does not do its own saving,
* as that is done by module_edit.coffee.
*/
CMS.Models.Metadata = Backbone.Model.extend({
defaults: {
"field_name": null,
"display_name": null,
"value" : null,
"explicitly_set": null,
"default_value" : null,
"options" : null,
"type" : null
},
initialize: function() {
this.original_value = this.get('value');
this.original_explicitly_set = this.get('explicitly_set');
},
/**
* Returns true if the stored value is different, or if the "explicitly_set"
* property has changed.
*/
isModified : function() {
if (!this.get('explicitly_set') && !this.original_explicitly_set) {
return false;
}
if (this.get('explicitly_set') && this.original_explicitly_set) {
return this.get('value') !== this.original_value;
}
return true;
},
/**
* Returns true if a non-default/non-inherited value has been set.
*/
isExplicitlySet: function() {
return this.get('explicitly_set');
},
/**
* The value, as shown in the UI. This may be an inherited or default value.
*/
getDisplayValue : function () {
return this.get('value');
},
/**
* The value, as should be returned to the server. if 'isExplicitlySet'
* returns false, this method returns null to indicate that the value
* is not set at this level.
*/
getValue: function() {
return this.get('explicitly_set') ? this.get('value') : null;
},
/**
* Sets the displayed value.
*/
setValue: function (value) {
this.set({
explicitly_set: true,
value: value
});
},
/**
* Returns the field name, which should be used for persisting the metadata
* field to the server.
*/
getFieldName: function () {
return this.get('field_name');
},
/**
* Returns the options. This may be a array of possible values, or an object
* with properties like "max", "min" and "step".
*/
getOptions: function () {
return this.get('options');
},
/**
* Returns the type of this metadata field. Possible values are SELECT_TYPE,
* INTEGER_TYPE, and FLOAT_TYPE, GENERIC_TYPE.
*/
getType: function() {
return this.get('type');
},
/**
* Reverts the value to the default_value specified at construction, and updates the
* explicitly_set property.
*/
clear: function() {
this.set({
explicitly_set: false,
value: this.get('default_value')
});
}
});
CMS.Models.MetadataCollection = Backbone.Collection.extend({
model : CMS.Models.Metadata,
comparator: "display_name"
});
CMS.Models.Metadata.SELECT_TYPE = "Select";
CMS.Models.Metadata.INTEGER_TYPE = "Integer";
CMS.Models.Metadata.FLOAT_TYPE = "Float";
CMS.Models.Metadata.GENERIC_TYPE = "Generic";
......@@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({
},
showNotification: function() {
if(!this.msg) {
this.msg = new CMS.Models.SystemFeedback({
intent: "saving",
title: gettext("Saving&hellip;")
});
}
if(!this.msgView) {
this.msgView = new CMS.Views.Notification({
model: this.msg,
this.msg = new CMS.Views.Notification.Saving({
title: gettext("Saving&hellip;"),
closeIcon: false,
minShown: 1250
});
}
this.msgView.show();
this.msg.show();
},
hideNotification: function() {
if(!this.msgView) { return; }
this.msgView.hide();
if(!this.msg) { return; }
this.msg.hide();
}
});
$(document).ready(function() {
$('.uploads .upload-button').bind('click', showUploadModal);
$('.upload-modal .close-button').bind('click', hideModal);
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
$('.remove-asset-button').bind('click', removeAsset);
});
function removeAsset(e){
e.preventDefault();
var that = this;
var msg = new CMS.Views.Prompt.Confirmation({
title: gettext("Delete File Confirmation"),
message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"),
actions: {
primary: {
text: gettext("OK"),
click: function(view) {
// call the back-end to actually remove the asset
var url = $('.asset-library').data('remove-asset-callback-url');
var row = $(that).closest('tr');
$.post(url,
{ 'location': row.data('id') },
function() {
// show the post-commit confirmation
$(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false');
row.remove();
analytics.track('Deleted Asset', {
'course': course_location_analytics,
'id': row.data('id')
});
}
);
view.hide();
}
},
secondary: [{
text: gettext("Cancel"),
click: function(view) {
view.hide();
}
}]
}
});
return msg.show();
}
function showUploadModal(e) {
e.preventDefault();
$modal = $('.upload-modal').show();
$('.file-input').bind('change', startUpload);
$modalCover.show();
}
function showFileSelectionMenu(e) {
e.preventDefault();
$('.file-input').click();
}
function startUpload(e) {
var files = $('.file-input').get(0).files;
if (files.length === 0)
return;
$('.upload-modal h1').html(gettext('Uploading…'));
$('.upload-modal .file-name').html(files[0].name);
$('.upload-modal .file-chooser').ajaxSubmit({
beforeSend: resetUploadBar,
uploadProgress: showUploadFeedback,
complete: displayFinishedUpload
});
$('.upload-modal .choose-file-button').hide();
$('.upload-modal .progress-bar').removeClass('loaded').show();
}
function resetUploadBar() {
var percentVal = '0%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
}
function showUploadFeedback(event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
}
function displayFinishedUpload(xhr) {
if (xhr.status == 200) {
markAsLoaded();
}
var resp = JSON.parse(xhr.responseText);
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
$('.upload-modal .embeddable').show();
$('.upload-modal .file-name').hide();
$('.upload-modal .progress-fill').html(resp.msg);
$('.upload-modal .choose-file-button').html(gettext('Load Another File')).show();
$('.upload-modal .progress-fill').width('100%');
// see if this id already exists, if so, then user must have updated an existing piece of content
$("tr[data-id='" + resp.url + "']").remove();
var template = $('#new-asset-element').html();
var html = Mustache.to_html(template, resp);
$('table > tbody').prepend(html);
// re-bind the listeners to delete it
$('.remove-asset-button').bind('click', removeAsset);
analytics.track('Uploaded a File', {
'course': course_location_analytics,
'asset_url': resp.url
});
}
CMS.Views.Alert = Backbone.View.extend({
CMS.Views.SystemFeedback = Backbone.View.extend({
options: {
type: "alert",
title: "",
message: "",
intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc
type: null, // "alert", "notification", or "prompt": set by subclass
shown: true, // is this view currently being shown?
icon: true, // should we render an icon related to the message intent?
closeIcon: true, // should we render a close button in the top right corner?
minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds)
maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds)
/* could also have an "actions" hash: here is an example demonstrating
the expected structure
actions: {
primary: {
"text": "Save",
"class": "action-save",
"click": function(view) {
// do something when Save is clicked
}
},
secondary: [
{
"text": "Cancel",
"class": "action-cancel",
"click": function(view) {}
}, {
"text": "Discard Changes",
"class": "action-discard",
"click": function(view) {}
}
]
}
*/
},
initialize: function() {
if(!this.options.type) {
throw "SystemFeedback: type required (given " +
JSON.stringify(this.options) + ")";
}
if(!this.options.intent) {
throw "SystemFeedback: intent required (given " +
JSON.stringify(this.options) + ")";
}
var tpl = $("#system-feedback-tpl").text();
if(!tpl) {
console.error("Couldn't load system-feedback template");
}
this.template = _.template(tpl);
this.setElement($("#page-"+this.options.type));
this.listenTo(this.model, 'change', this.render);
return this.show();
},
render: function() {
var attrs = $.extend({}, this.options, this.model.attributes);
this.$el.html(this.template(attrs));
return this;
},
events: {
"click .action-close": "hide",
"click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick"
},
// public API: show() and hide()
show: function() {
clearTimeout(this.hideTimeout);
this.options.shown = true;
this.shownAt = new Date();
this.render();
if($.isNumeric(this.options.maxShown)) {
this.hideTimeout = setTimeout($.proxy(this.hide, this),
this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.maxShown);
}
return this;
......@@ -43,7 +68,7 @@ CMS.Views.Alert = Backbone.View.extend({
this.options.minShown > new Date() - this.shownAt)
{
clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout($.proxy(this.hide, this),
this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.minShown - (new Date() - this.shownAt));
} else {
this.options.shown = false;
......@@ -52,40 +77,64 @@ CMS.Views.Alert = Backbone.View.extend({
}
return this;
},
primaryClick: function() {
var actions = this.model.get("actions");
// the rest of the API should be considered semi-private
events: {
"click .action-close": "hide",
"click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick"
},
render: function() {
// there can be only one active view of a given type at a time: only
// one alert, only one notification, only one prompt. Therefore, we'll
// use a singleton approach.
var parent = CMS.Views[_.str.capitalize(this.options.type)];
if(parent && parent.active && parent.active !== this) {
parent.active.stopListening();
parent.active.undelegateEvents();
}
this.$el.html(this.template(this.options));
parent.active = this;
return this;
},
primaryClick: function(event) {
var actions = this.options.actions;
if(!actions) { return; }
var primary = actions.primary;
if(!primary) { return; }
if(primary.click) {
primary.click.call(this.model, this);
primary.click.call(event.target, this, event);
}
},
secondaryClick: function(e) {
var actions = this.model.get("actions");
secondaryClick: function(event) {
var actions = this.options.actions;
if(!actions) { return; }
var secondaryList = actions.secondary;
if(!secondaryList) { return; }
// which secondary action was clicked?
var i = 0; // default to the first secondary action (easier for testing)
if(e && e.target) {
i = _.indexOf(this.$(".action-secondary"), e.target);
if(event && event.target) {
i = _.indexOf(this.$(".action-secondary"), event.target);
}
var secondary = this.model.get("actions").secondary[i];
var secondary = secondaryList[i];
if(secondary.click) {
secondary.click.call(this.model, this);
secondary.click.call(event.target, this, event);
}
}
});
CMS.Views.Notification = CMS.Views.Alert.extend({
options: $.extend({}, CMS.Views.Alert.prototype.options, {
CMS.Views.Alert = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "alert"
})
});
CMS.Views.Notification = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "notification",
closeIcon: false
})
});
CMS.Views.Prompt = CMS.Views.Alert.extend({
options: $.extend({}, CMS.Views.Alert.prototype.options, {
CMS.Views.Prompt = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "prompt",
closeIcon: false,
icon: false
......@@ -98,6 +147,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({
$body.removeClass('prompt-is-shown');
}
// super() in Javascript has awkward syntax :(
return CMS.Views.Alert.prototype.render.apply(this, arguments);
return CMS.Views.SystemFeedback.prototype.render.apply(this, arguments);
}
});
// create CMS.Views.Alert.Warning, CMS.Views.Notification.Confirmation,
// CMS.Views.Prompt.StepRequired, etc
var capitalCamel, types, intents;
capitalCamel = _.compose(_.str.capitalize, _.str.camelize);
types = ["alert", "notification", "prompt"];
intents = ["warning", "error", "confirmation", "announcement", "step-required", "help", "saving"];
_.each(types, function(type) {
_.each(intents, function(intent) {
// "class" is a reserved word in Javascript, so use "klass" instead
var klass, subklass;
klass = CMS.Views[capitalCamel(type)];
subklass = klass.extend({
options: $.extend({}, klass.prototype.options, {
type: type,
intent: intent
})
});
klass[capitalCamel(intent)] = subklass;
});
});
......@@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({
showInvalidMessage: function(model, error, options) {
model.set("name", model.previous("name"));
var that = this;
var msg = new CMS.Models.ErrorMessage({
var prompt = new CMS.Views.Prompt.Error({
title: gettext("Your change could not be saved"),
message: error,
actions: {
......@@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({
}
}
});
new CMS.Views.Prompt({model: msg});
prompt.show();
}
});
......@@ -14,7 +14,7 @@ body {
color: $gray-d2;
}
body, input {
body, input, button {
font-family: 'Open Sans', sans-serif;
}
......@@ -814,7 +814,7 @@ hr.divide {
line-height: 26px;
color: $white;
pointer-events: none;
opacity: 0;
opacity: 0.0;
&:after {
content: '▾';
......
......@@ -149,11 +149,11 @@ abbr[title] {
margin-left: 20px;
}
li {
opacity: .8;
opacity: 0.8;
&:ui-state-active {
background-color: rgba(255, 255, 255, .3);
opacity: 1;
opacity: 1.0;
font-weight: 400;
}
a:focus {
......
......@@ -95,12 +95,12 @@
// bounce in
@mixin bounceIn {
0% {
opacity: 0;
opacity: 0.0;
@include transform(scale(0.3));
}
50% {
opacity: 1;
opacity: 1.0;
@include transform(scale(1.05));
}
......@@ -128,12 +128,12 @@
// bounce in
@mixin bounceOut {
0% {
opacity: 0;
opacity: 0.0;
@include transform(scale(0.3));
}
50% {
opacity: 1;
opacity: 1.0;
@include transform(scale(1.05));
}
......@@ -146,12 +146,12 @@
}
50% {
opacity: 1;
opacity: 1.0;
@include transform(scale(1.05));
}
100% {
opacity: 0;
opacity: 0.0;
@include transform(scale(0.3));
}
}
......
......@@ -124,7 +124,6 @@ code {
.CodeMirror {
font-size: 13px;
border: 1px solid $darkGrey;
background: #fff;
}
......
......@@ -243,7 +243,7 @@
left: -7px;
top: 47px;
width: 140px;
opacity: 0;
opacity: 0.0;
pointer-events: none;
}
......@@ -558,7 +558,7 @@ body.signin .nav-not-signedin-signup {
.wrapper-nav-sub {
@include transition (opacity 1.0s ease-in-out 0s);
opacity: 0;
opacity: 0.0;
pointer-events: none;
&.is-shown {
......
......@@ -627,7 +627,7 @@
pointer-events: none;
.prompt {
opacity: 0;
opacity: 0.0;
}
}
......
// studio - elements - system help
// ====================
// notices - in-context: to be used as notices to users within the context of a form/action
.notice-incontext {
@extend .ui-well;
@include border-radius(($baseline/10));
.title {
@extend .t-title7;
margin-bottom: ($baseline/4);
font-weight: 600;
}
.copy {
@extend .t-copy-sub1;
@include transition(opacity 0.25s ease-in-out 0);
opacity: 0.75;
}
strong {
font-weight: 600;
}
&:hover {
.copy {
opacity: 1.0;
}
}
}
// particular warnings around a workflow for something
.notice-workflow {
background: $yellow-l5;
.copy {
color: $gray-d1;
}
}
......@@ -76,6 +76,10 @@ body.course.uploads {
width: 250px;
}
.delete-col {
width: 20px;
}
.embeddable-xml-input {
@include box-shadow(none);
width: 100%;
......
......@@ -254,7 +254,7 @@ body.course.checklists {
.task-support {
@extend .t-copy-sub2;
@include transition(opacity .15s .25s ease-in-out);
opacity: 0;
opacity: 0.0;
pointer-events: none;
}
}
......@@ -267,7 +267,7 @@ body.course.checklists {
float: right;
width: flex-grid(2,9);
margin: ($baseline/2) 0 0 flex-gutter();
opacity: 0;
opacity: 0.0;
pointer-events: none;
text-align: right;
......
......@@ -59,7 +59,7 @@ body.dashboard {
top: 15px;
right: $baseline;
padding: ($baseline/4) ($baseline/2);
opacity: 0;
opacity: 0.0;
pointer-events: none;
&:hover {
......
......@@ -162,7 +162,7 @@ body.index {
position: absolute;
bottom: -30px;
right: ($baseline/2);
opacity: 0;
opacity: 0.0;
[class^="icon-"] {
@include font-size(18);
......
......@@ -52,6 +52,12 @@ body.course.settings {
}
}
// notices - used currently for edx mktg
.notice-workflow {
margin-top: ($baseline);
}
// in form - elements
.group-settings {
margin: 0 0 ($baseline*2) 0;
......@@ -386,6 +392,11 @@ body.course.settings {
#course-overview {
height: ($baseline*20);
}
//adds back in CodeMirror border removed due to Unit page styling of component editors
.CodeMirror {
border: 1px solid $gray-l2;
}
}
// specific fields - video
......@@ -698,7 +709,7 @@ body.course.settings {
.tip {
@include transition (opacity 0.5s ease-in-out 0s);
opacity: 0;
opacity: 0.0;
position: absolute;
bottom: ($baseline*1.25);
}
......@@ -713,7 +724,7 @@ body.course.settings {
input.error {
& + .tip {
opacity: 0;
opacity: 0.0;
}
}
}
......
......@@ -41,38 +41,23 @@ body.course.static-pages {
@include edit-box;
@include box-shadow(none);
display: none;
padding: 20px;
padding: 0;
border-radius: 2px 2px 0 0;
.metadata_edit {
margin-bottom: 20px;
font-size: 13px;
li {
margin-bottom: 10px;
}
label {
display: inline-block;
margin-right: 10px;
}
//Overrides general edit-box mixin
.row {
margin-bottom: 0px;
}
h3 {
margin-bottom: 10px;
font-size: 18px;
font-weight: 700;
}
// This duplicates the styling from Unit page editing
.module-actions {
@include box-shadow(inset 0 1px 1px $shadow);
padding: 0px 0 10px 10px;
background-color: $gray-l6;
h5 {
margin-bottom: 8px;
color: #fff;
font-weight: 700;
}
.save-button {
margin-top: 10px;
margin: 15px 8px 0 0;
.save-button {
margin: ($baseline/2) 8px 0 0;
}
}
}
}
......@@ -215,3 +200,4 @@ body.course.static-pages {
outline: 0;
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment