Merge remote-tracking branch 'origin/master' into bugfix/brian/openid_provider_post

This commit is contained in:
Brian Wilson
2013-01-22 23:50:38 -05:00
790 changed files with 72118 additions and 4633 deletions

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ lms/lib/comment_client/python
nosetests.xml
cover_html/
.idea/
chromedriver.log

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "common/test/phantom-jasmine"]
path = common/test/phantom-jasmine
url = https://github.com/jcarver989/phantom-jasmine.git

View File

@@ -3,3 +3,5 @@ ruby "1.9.3"
gem 'rake'
gem 'sass', '3.1.15'
gem 'bourbon', '~> 1.3.6'
gem 'colorize'
gem 'launchy'

21
cms/CHANGELOG.md Normal file
View File

@@ -0,0 +1,21 @@
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

View File

@@ -6,21 +6,33 @@ from django.core.exceptions import PermissionDenied
from xmodule.modulestore import Location
'''
This code is somewhat duplicative of access.py in the LMS. We will unify the code as a separate story
but this implementation should be data compatible with the LMS implementation
'''
# define a couple of simple roles, we just need ADMIN and EDITOR now for our purposes
ADMIN_ROLE_NAME = 'admin'
EDITOR_ROLE_NAME = 'editor'
INSTRUCTOR_ROLE_NAME = 'instructor'
STAFF_ROLE_NAME = 'staff'
# we're just making a Django group for each location/role combo
# to do this we're just creating a Group name which is a formatted string
# of those two variables
def get_course_groupname_for_role(location, role):
loc = Location(location)
groupname = loc.course_id + ':' + role
# hack: check for existence of a group name in the legacy LMS format <role>_<course>
# if it exists, then use that one, otherwise use a <role>_<course_id> which contains
# more information
groupname = '{0}_{1}'.format(role, loc.course)
if len(Group.objects.filter(name = groupname)) == 0:
groupname = '{0}_{1}'.format(role,loc.course_id)
return groupname
def get_users_in_course_group_by_role(location, role):
groupname = get_course_groupname_for_role(location, role)
group = Group.objects.get(name=groupname)
(group, created) = Group.objects.get_or_create(name=groupname)
return group.user_set.all()
@@ -28,13 +40,13 @@ 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, ADMIN_GROUP_NAME)
create_new_course_group(creator, location, EDITOR_GROUP_NAME)
create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME)
create_new_course_group(creator, location, STAFF_ROLE_NAME)
def create_new_course_group(creator, location, role):
groupname = get_course_groupname_for_role(location, role)
(group, created) =Group.get_or_create(name=groupname)
(group, created) =Group.objects.get_or_create(name=groupname)
if created:
group.save()
@@ -43,10 +55,43 @@ 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):
# remove all memberships
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all():
user.groups.remove(instructors)
user.save()
staff = Group.objects.get(name=get_course_groupname_for_role(location, STAFF_ROLE_NAME))
for user in staff.user_set.all():
user.groups.remove(staff)
user.save()
'''
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):
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME))
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all():
user.groups.add(new_instructors_group)
user.save()
staff = Group.objects.get(name=get_course_groupname_for_role(source, STAFF_ROLE_NAME))
new_staff_group = Group.objects.get(name=get_course_groupname_for_role(dest, STAFF_ROLE_NAME))
for user in staff.user_set.all():
user.groups.add(new_staff_group)
user.save()
def add_user_to_course_group(caller, user, location, role):
# only admins can add/remove other users
if not is_user_in_course_group_role(caller, location, ADMIN_ROLE_NAME):
if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME):
raise PermissionDenied
if user.is_active and user.is_authenticated:
@@ -73,7 +118,7 @@ def get_user_by_email(email):
def remove_user_from_course_group(caller, user, location, role):
# only admins can add/remove other users
if not is_user_in_course_group_role(caller, location, ADMIN_ROLE_NAME):
if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME):
raise PermissionDenied
# see if the user is actually in that role, if not then we don't have to do anything
@@ -87,7 +132,8 @@ def remove_user_from_course_group(caller, user, location, role):
def is_user_in_course_group_role(user, location, role):
if user.is_active and user.is_authenticated:
return user.groups.filter(name=get_course_groupname_for_role(location,role)).count() > 0
# all "is_staff" flagged accounts belong to all groups
return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location,role)).count() > 0
return False

View File

@@ -0,0 +1,134 @@
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from lxml import html
import re
from django.http import HttpResponseBadRequest
import logging
## TODO store as array of { date, content } and override course_info_module.definition_from_xml
## This should be in a class which inherits from XmlDescriptor
def get_course_updates(location):
"""
Retrieve the relevant course_info updates and unpack into the model which the client expects:
[{id : location.url() + idx to make unique, date : string, content : html string}]
"""
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"])
course_updates = modulestore('direct').clone_item(template, Location(location))
# current db rep: {"_id" : locationjson, "definition" : { "data" : "<ol>[<li><h2>date</h2>content</li>]</ol>"} "metadata" : ignored}
location_base = course_updates.location.url()
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = html.fromstring(course_updates.definition['data'])
except:
course_html_parsed = html.fromstring("<ol></ol>")
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
course_upd_collection = []
if course_html_parsed.tag == 'ol':
# 0 is the newest
for idx, update in enumerate(course_html_parsed):
if (len(update) == 0):
continue
elif (len(update) == 1):
# could enforce that update[0].tag == 'h2'
content = update[0].tail
else:
content = "\n".join([html.tostring(ele) for ele in update[1:]])
# make the id on the client be 1..len w/ 1 being the oldest and len being the newest
course_upd_collection.append({"id" : location_base + "/" + str(len(course_html_parsed) - idx),
"date" : update.findtext("h2"),
"content" : content})
return course_upd_collection
def update_course_updates(location, update, passed_id=None):
"""
Either add or update the given course update. It will add it if the passed_id is absent or None. It will update it if
it has an passed_id which has a valid value. Until updates have distinct values, the passed_id is the location url + an index
into the html structure.
"""
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
return HttpResponseBadRequest
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = html.fromstring(course_updates.definition['data'])
except:
course_html_parsed = html.fromstring("<ol></ol>")
# No try/catch b/c failure generates an error back to client
new_html_parsed = html.fromstring('<li><h2>' + update['date'] + '</h2>' + update['content'] + '</li>')
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
if course_html_parsed.tag == 'ol':
# ??? Should this use the id in the json or in the url or does it matter?
if passed_id is not None:
idx = get_idx(passed_id)
# idx is count from end of list
course_html_parsed[-idx] = new_html_parsed
else:
course_html_parsed.insert(0, new_html_parsed)
idx = len(course_html_parsed)
passed_id = course_updates.location.url() + "/" + str(idx)
# update db record
course_updates.definition['data'] = html.tostring(course_html_parsed)
modulestore('direct').update_item(location, course_updates.definition['data'])
return {"id" : passed_id,
"date" : update['date'],
"content" :update['content']}
def delete_course_update(location, update, passed_id):
"""
Delete the given course_info update from the db.
Returns the resulting course_updates b/c their ids change.
"""
if not passed_id:
return HttpResponseBadRequest
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
return HttpResponseBadRequest
# TODO use delete_blank_text parser throughout and cache as a static var in a class
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = html.fromstring(course_updates.definition['data'])
except:
course_html_parsed = html.fromstring("<ol></ol>")
if course_html_parsed.tag == 'ol':
# ??? Should this use the id in the json or in the url or does it matter?
idx = get_idx(passed_id)
# idx is count from end of list
element_to_delete = course_html_parsed[-idx]
if element_to_delete is not None:
course_html_parsed.remove(element_to_delete)
# update db record
course_updates.definition['data'] = html.tostring(course_html_parsed)
store = modulestore('direct')
store.update_item(location, course_updates.definition['data'])
return get_course_updates(location)
def get_idx(passed_id):
"""
From the url w/ idx appended, get the idx.
"""
# TODO compile this regex into a class static and reuse for each call
idx_matcher = re.search(r'.*/(\d+)$', passed_id)
if idx_matcher:
return int(idx_matcher.group(1))

View File

@@ -0,0 +1,131 @@
from lettuce import world, step
from factories import *
from django.core.management import call_command
from lettuce.django import django_url
from django.conf import settings
from django.core.management import call_command
from nose.tools import assert_true
from nose.tools import assert_equal
import xmodule.modulestore.django
from logging import getLogger
logger = getLogger(__name__)
########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$')
def i_visit_the_studio_homepage(step):
# To make this go to port 8001, put
# LETTUCE_SERVER_PORT = 8001
# in your settings.py file.
world.browser.visit(django_url('/'))
assert world.browser.is_element_present_by_css('body.no-header', 10)
@step('I am logged into Studio$')
def i_am_logged_into_studio(step):
log_into_studio()
@step('I confirm the alert$')
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):
if category == 'section':
css = 'a.delete-button.delete-section-button span.delete-icon'
elif category == 'subsection':
css='a.delete-button.delete-subsection-button span.delete-icon'
else:
assert False, 'Invalid category: %s' % category
css_click(css)
####### HELPER FUNCTIONS ##############
def create_studio_user(
uname='robot',
email='robot+studio@edx.org',
password='test',
is_staff=False):
studio_user = UserFactory.build(
username=uname,
email=email,
password=password,
is_staff=is_staff)
studio_user.set_password(password)
studio_user.save()
registration = RegistrationFactory(user=studio_user)
registration.register(studio_user)
registration.activate()
user_profile = UserProfileFactory(user=studio_user)
def flush_xmodule_store():
# Flush and initialize the module store
# It needs the templates because it creates new records
# by cloning from the template.
# Note that if your test module gets in some weird state
# (though it shouldn't), do this manually
# from the bash shell to drop it:
# $ mongo test_xmodule --eval "db.dropDatabase()"
xmodule.modulestore.django._MODULESTORES = {}
xmodule.modulestore.django.modulestore().collection.drop()
xmodule.templates.update_templates()
def assert_css_with_text(css,text):
assert_true(world.browser.is_element_present_by_css(css, 5))
assert_equal(world.browser.find_by_css(css).text, text)
def css_click(css):
world.browser.find_by_css(css).first.click()
def css_fill(css, value):
world.browser.find_by_css(css).first.fill(value)
def clear_courses():
flush_xmodule_store()
def fill_in_course_info(
name='Robot Super Course',
org='MITx',
num='101'):
css_fill('.new-course-name',name)
css_fill('.new-course-org',org)
css_fill('.new-course-number',num)
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)
world.browser.cookies.delete()
world.browser.visit(django_url('/'))
world.browser.is_element_present_by_css('body.no-header', 10)
login_form = world.browser.find_by_css('form#login_form')
login_form.find_by_name('email').fill(email)
login_form.find_by_name('password').fill(password)
login_form.find_by_name('submit').click()
assert_true(world.browser.is_element_present_by_css('.new-course-button', 5))
def create_a_course():
css_click('a.new-course-button')
fill_in_course_info()
css_click('input.new-course-save')
assert_true(world.browser.is_element_present_by_css('a#courseware-tab', 5))
def add_section(name='My Section'):
link_css = 'a.new-courseware-section-button'
css_click(link_css)
name_css = '.new-section-name'
save_css = '.new-section-name-save'
css_fill(name_css,name)
css_click(save_css)
def add_subsection(name='Subsection One'):
css = 'a.new-subsection-item'
css_click(css)
name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save'
css_fill(name_css, name)
css_click(save_css)

View File

@@ -0,0 +1,13 @@
Feature: Create Course
In order offer a course on the edX platform
As a course author
I want to create courses
Scenario: Create a course
Given There are no courses
And I am logged into Studio
When I click the New Course button
And I fill in the new course information
And I press the "Save" button
Then the Courseware page has loaded in Studio
And I see a link for adding a new section

View File

@@ -0,0 +1,50 @@
from lettuce import world, step
from common import *
############### ACTIONS ####################
@step('There are no courses$')
def no_courses(step):
clear_courses()
@step('I click the New Course button$')
def i_click_new_course(step):
css_click('.new-course-button')
@step('I fill in the new course information$')
def i_fill_in_a_new_course_information(step):
fill_in_course_info()
@step('I create a new course$')
def i_create_a_course(step):
create_a_course()
@step('I click the course link in My Courses$')
def i_click_the_course_link_in_my_courses(step):
course_css = 'span.class-name'
css_click(course_css)
############ ASSERTIONS ###################
@step('the Courseware page has loaded in Studio$')
def courseware_page_has_loaded_in_studio(step):
courseware_css = 'a#courseware-tab'
assert world.browser.is_element_present_by_css(courseware_css)
@step('I see the course listed in My Courses$')
def i_see_the_course_in_my_courses(step):
course_css = 'span.class-name'
assert_css_with_text(course_css,'Robot Super Course')
@step('the course is loaded$')
def course_is_loaded(step):
class_css = 'a.class-name'
assert_css_with_text(class_css,'Robot Super Course')
@step('I am on the "([^"]*)" tab$')
def i_am_on_tab(step, tab_name):
header_css = 'div.inner-wrapper h1'
assert_css_with_text(header_css,tab_name)
@step('I see a link for adding a new section$')
def i_see_new_section_link(step):
link_css = 'a.new-courseware-section-button'
assert_css_with_text(link_css,'New Section')

View File

@@ -0,0 +1,31 @@
import factory
from student.models import User, UserProfile, Registration
from datetime import datetime
import uuid
class UserProfileFactory(factory.Factory):
FACTORY_FOR = UserProfile
user = None
name = 'Robot Studio'
courseware = 'course.xml'
class RegistrationFactory(factory.Factory):
FACTORY_FOR = Registration
user = None
activation_key = uuid.uuid4().hex
class UserFactory(factory.Factory):
FACTORY_FOR = User
username = 'robot-studio'
email = 'robot+studio@edx.org'
password = 'test'
first_name = 'Robot'
last_name = 'Studio'
is_staff = False
is_active = True
is_superuser = False
last_login = datetime.now()
date_joined = datetime.now()

View File

@@ -0,0 +1,26 @@
Feature: Create Section
In order offer a course on the edX platform
As a course author
I want to create and edit sections
Scenario: Add a new section to a course
Given I have opened a new course in Studio
When I click the New Section link
And I enter the section name and click save
Then I see my section on the Courseware page
And I see a release date for my section
And I see a link to create a new subsection
Scenario: Edit section release date
Given I have opened a new course in Studio
And I have added a new section
When I click the Edit link for the release date
And I save a new section release date
Then the section release date is updated
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
Then the section does not exist

View File

@@ -0,0 +1,82 @@
from lettuce import world, step
from common import *
############### ACTIONS ####################
@step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step):
clear_courses()
log_into_studio()
create_a_course()
@step('I click the new section link$')
def i_click_new_section_link(step):
link_css = 'a.new-courseware-section-button'
css_click(link_css)
@step('I enter the section name and click save$')
def i_save_section_name(step):
name_css = '.new-section-name'
save_css = '.new-section-name-save'
css_fill(name_css,'My Section')
css_click(save_css)
@step('I have added a new section$')
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):
button_css = 'div.section-published-date a.edit-button'
css_click(button_css)
@step('I save a new section release date$')
def i_save_a_new_section_release_date(step):
date_css = 'input.start-date.date.hasDatepicker'
time_css = 'input.start-time.time.ui-timepicker-input'
css_fill(date_css,'12/25/2013')
# click here to make the calendar go away
css_click(time_css)
css_fill(time_css,'12:00am')
css_click('a.save-button')
############ ASSERTIONS ###################
@step('I see my section on the Courseware page$')
def i_see_my_section_on_the_courseware_page(step):
section_css = 'span.section-name-span'
assert_css_with_text(section_css,'My Section')
@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)
@step('I see a release date for my section$')
def i_see_a_release_date_for_my_section(step):
import re
css = 'span.published-status'
assert world.browser.is_element_present_by_css(css)
status_text = world.browser.find_by_css(css).text
# 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)
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):
css = 'a.new-subsection-item'
assert world.browser.is_element_present_by_css(css)
@step('the section release date picker is not visible$')
def the_section_release_date_picker_not_visible(step):
css = 'div.edit-subsection-publish-settings'
assert False, world.browser.find_by_css(css).visible
@step('the section release date is updated$')
def the_section_release_date_is_updated(step):
css = 'span.published-status'
status_text = world.browser.find_by_css(css).text
assert status_text == 'Will Release: 12/25/2013 at 12:00am'

View File

@@ -0,0 +1,12 @@
Feature: Sign in
In order to use the edX content
As a new user
I want to signup for a student account
Scenario: Sign up from the homepage
Given I visit the Studio homepage
When I click the link with the text "Sign up"
And I fill in the registration form
And I press the "Create My Account" button on the registration form
Then I should see be on the studio home page
And I should see the message "please click on the activation link in your email."

View File

@@ -0,0 +1,23 @@
from lettuce import world, step
@step('I fill in the registration form$')
def i_fill_in_the_registration_form(step):
register_form = world.browser.find_by_css('form#register_form')
register_form.find_by_name('email').fill('robot+studio@edx.org')
register_form.find_by_name('password').fill('test')
register_form.find_by_name('username').fill('robot-studio')
register_form.find_by_name('name').fill('Robot Studio')
register_form.find_by_name('terms_of_service').check()
@step('I press the "([^"]*)" button on the registration form$')
def i_press_the_button_on_the_registration_form(step, button):
register_form = world.browser.find_by_css('form#register_form')
register_form.find_by_value(button).click()
@step('I should see be on the studio home page$')
def i_should_see_be_on_the_studio_home_page(step):
assert world.browser.find_by_css('div.inner-wrapper')
@step(u'I should see the message "([^"]*)"$')
def i_should_see_the_message(step, msg):
assert world.browser.is_text_present(msg, 5)

View File

@@ -0,0 +1,59 @@
Feature: Overview Toggle Section
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
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

View File

@@ -0,0 +1,104 @@
from lettuce import world, step
from terrain.factories import *
from common import *
from nose.tools import assert_true, assert_false, assert_equal
from logging import getLogger
logger = getLogger(__name__)
@step(u'I have a course with no sections$')
def have_a_course(step):
clear_courses()
course = CourseFactory.create()
@step(u'I have a course with 1 section$')
def have_a_course_with_1_section(step):
clear_courses()
course = CourseFactory.create()
section = ItemFactory.create(parent_location=course.location)
subsection1 = ItemFactory.create(
parent_location=section.location,
template = 'i4x://edx/templates/sequential/Empty',
display_name = 'Subsection One',)
@step(u'I have a course with multiple sections$')
def have_a_course_with_two_sections(step):
clear_courses()
course = CourseFactory.create()
section = ItemFactory.create(parent_location=course.location)
subsection1 = ItemFactory.create(
parent_location=section.location,
template = 'i4x://edx/templates/sequential/Empty',
display_name = 'Subsection One',)
section2 = ItemFactory.create(
parent_location=course.location,
display_name='Section Two',)
subsection2 = ItemFactory.create(
parent_location=section2.location,
template = 'i4x://edx/templates/sequential/Empty',
display_name = 'Subsection Alpha',)
subsection3 = ItemFactory.create(
parent_location=section2.location,
template = 'i4x://edx/templates/sequential/Empty',
display_name = 'Subsection Beta',)
@step(u'I navigate to the course overview page$')
def navigate_to_the_course_overview_page(step):
log_into_studio(is_staff=True)
course_locator = '.class-name'
css_click(course_locator)
@step(u'I navigate to the courseware page of a course with multiple sections')
def nav_to_the_courseware_page_of_a_course_with_multiple_sections(step):
step.given('I have a course with multiple sections')
step.given('I navigate to the course overview page')
@step(u'I add a section')
def i_add_a_section(step):
add_section(name='My New Section That I Just Added')
@step(u'I click the "([^"]*)" link$')
def i_click_the_text_span(step, text):
span_locator = '.toggle-button-sections span'
assert_true(world.browser.is_element_present_by_css(span_locator, 5))
# first make sure that the expand/collapse text is the one you expected
assert_equal(world.browser.find_by_css(span_locator).value, text)
css_click(span_locator)
@step(u'I collapse the first section$')
def i_collapse_a_section(step):
collapse_locator = 'section.courseware-section a.collapse'
css_click(collapse_locator)
@step(u'I expand the first section$')
def i_expand_a_section(step):
expand_locator = 'section.courseware-section a.expand'
css_click(expand_locator)
@step(u'I see the "([^"]*)" link$')
def i_see_the_span_with_text(step, text):
span_locator = '.toggle-button-sections span'
assert_true(world.browser.is_element_present_by_css(span_locator, 5))
assert_equal(world.browser.find_by_css(span_locator).value, text)
assert_true(world.browser.find_by_css(span_locator).visible)
@step(u'I do not see the "([^"]*)" link$')
def i_do_not_see_the_span_with_text(step, text):
# Note that the span will exist on the page but not be visible
span_locator = '.toggle-button-sections span'
assert_true(world.browser.is_element_present_by_css(span_locator))
assert_false(world.browser.find_by_css(span_locator).visible)
@step(u'all sections are expanded$')
def all_sections_are_expanded(step):
subsection_locator = 'div.subsection-list'
subsections = world.browser.find_by_css(subsection_locator)
for s in subsections:
assert_true(s.visible)
@step(u'all sections are collapsed$')
def all_sections_are_expanded(step):
subsection_locator = 'div.subsection-list'
subsections = world.browser.find_by_css(subsection_locator)
for s in subsections:
assert_false(s.visible)

View File

@@ -0,0 +1,18 @@
Feature: Create Subsection
In order offer a course on the edX platform
As a course author
I want to create and edit subsections
Scenario: Add a new subsection to a section
Given I have opened a new course section in Studio
When I click the New Subsection link
And I enter the subsection name and click save
Then I see my subsection on the Courseware page
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
Then the subsection does not exist

View File

@@ -0,0 +1,39 @@
from lettuce import world, step
from common import *
############### ACTIONS ####################
@step('I have opened a new course section in Studio$')
def i_have_opened_a_new_course_section(step):
clear_courses()
log_into_studio()
create_a_course()
add_section()
@step('I click the New Subsection link')
def i_click_the_new_subsection_link(step):
css = 'a.new-subsection-item'
css_click(css)
@step('I enter the subsection name and click save$')
def i_save_subsection_name(step):
name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save'
css_fill(name_css,'Subsection One')
css_click(save_css)
@step('I have added a new subsection$')
def i_have_added_a_new_subsection(step):
add_subsection()
############ ASSERTIONS ###################
@step('I see my subsection on the Courseware page$')
def i_see_my_subsection_on_the_courseware_page(step):
css = 'span.subsection-name'
assert world.browser.is_element_present_by_css(css)
css = 'span.subsection-name-value'
assert_css_with_text(css,'Subsection One')
@step('the subsection does not exist$')
def the_subsection_does_not_exist(step):
css = 'span.subsection-name'
assert world.browser.is_element_not_present_by_css(css)

View File

@@ -0,0 +1,38 @@
###
### Script for cloning a course
###
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.store_utilities import clone_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
from auth.authz import _copy_course_group
#
# To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3
#
class Command(BaseCommand):
help = \
'''Clone a MongoDB backed course to another location'''
def handle(self, *args, **options):
if len(args) != 2:
raise CommandError("clone requires two arguments: <source-location> <dest-location>")
source_location_str = args[0]
dest_location_str = args[1]
ms = modulestore('direct')
cs = contentstore()
print "Cloning course {0} to {1}".format(source_location_str, dest_location_str)
source_location = CourseDescriptor.id_to_location(source_location_str)
dest_location = CourseDescriptor.id_to_location(dest_location_str)
if clone_course(ms, cs, source_location, dest_location):
print "copying User permissions..."
_copy_course_group(source_location, dest_location)

View File

@@ -0,0 +1,40 @@
###
### Script for cloning a course
###
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
from prompt import query_yes_no
from auth.authz import _delete_course_group
#
# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
#
class Command(BaseCommand):
help = \
'''Delete a MongoDB backed course'''
def handle(self, *args, **options):
if len(args) != 1:
raise CommandError("delete_course requires one argument: <location>")
loc_str = args[0]
ms = modulestore('direct')
cs = contentstore()
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
loc = CourseDescriptor.id_to_location(loc_str)
if delete_course(ms, cs, loc) == True:
print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course
_delete_course_group(loc)

View File

@@ -0,0 +1,35 @@
###
### Script for exporting courseware from Mongo to a tar.gz file
###
import os
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
unnamed_modules = 0
class Command(BaseCommand):
help = \
'''Import the specified data directory into the default ModuleStore'''
def handle(self, *args, **options):
if len(args) != 2:
raise CommandError("import requires two arguments: <course location> <output path>")
course_id = args[0]
output_path = args[1]
print "Exporting course id = {0} to {1}".format(course_id, output_path)
location = CourseDescriptor.id_to_location(course_id)
root_dir = os.path.dirname(output_path)
course_dir = os.path.splitext(os.path.basename(output_path))[0]
export_to_xml(modulestore('direct'), contentstore(), location, root_dir, course_dir)

View File

@@ -5,6 +5,7 @@
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
unnamed_modules = 0
@@ -26,4 +27,5 @@ class Command(BaseCommand):
print "Importing. Data_dir={data}, course_dirs={courses}".format(
data=data_dir,
courses=course_dirs)
import_from_xml(modulestore(), data_dir, course_dirs, load_error_modules=False)
import_from_xml(modulestore('direct'), data_dir, course_dirs, load_error_modules=False,
static_content_store=contentstore(), verbose=True)

View File

@@ -0,0 +1,33 @@
import sys
def query_yes_no(question, default="yes"):
"""Ask a yes/no question via raw_input() and return their answer.
"question" is a string that is presented to the user.
"default" is the presumed answer if the user just hits <Enter>.
It must be "yes" (the default), "no" or None (meaning
an answer is required of the user).
The "answer" return value is one of "yes" or "no".
"""
valid = {"yes":True, "y":True, "ye":True,
"no":False, "n":False}
if default == None:
prompt = " [y/n] "
elif default == "yes":
prompt = " [Y/n] "
elif default == "no":
prompt = " [y/N] "
else:
raise ValueError("invalid default answer: '%s'" % default)
while True:
sys.stdout.write(question + prompt)
choice = raw_input().lower()
if default is not None and choice == '':
return valid[default]
elif choice in valid:
return valid[choice]
else:
sys.stdout.write("Please respond with 'yes' or 'no' "\
"(or 'y' or 'n').\n")

View File

@@ -0,0 +1,28 @@
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.xml_importer import perform_xlint
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
unnamed_modules = 0
class Command(BaseCommand):
help = \
'''
Verify the structure of courseware as to it's suitability for import
To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)]
'''
def handle(self, *args, **options):
if len(args) == 0:
raise CommandError("import requires at least one argument: <data directory> [<course dir>...]")
data_dir = args[0]
if len(args) > 1:
course_dirs = args[1:]
else:
course_dirs = None
print "Importing. Data_dir={data}, course_dirs={courses}".format(
data=data_dir,
courses=course_dirs)
perform_xlint(data_dir, course_dirs, load_error_modules=False)

View File

@@ -0,0 +1,84 @@
import logging
from static_replace import replace_urls
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from lxml import etree
import re
from django.http import HttpResponseBadRequest, Http404
def get_module_info(store, location, parent_location = None, rewrite_static_links = False):
try:
if location.revision is None:
module = store.get_item(location)
else:
module = store.get_item(location)
except ItemNotFoundError:
raise Http404
data = module.definition['data']
if rewrite_static_links:
data = replace_urls(module.definition['data'], course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None]))
return {
'id': module.location.url(),
'data': data,
'metadata': module.metadata
}
def set_module_info(store, location, post_data):
module = None
isNew = False
try:
if location.revision is None:
module = store.get_item(location)
else:
module = store.get_item(location)
except:
pass
if module is None:
# new module at this location
# presume that we have an 'Empty' template
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
module = store.clone_item(template_location, location)
isNew = True
if post_data.get('data') is not None:
data = post_data['data']
store.update_item(location, data)
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
# deleting the children object from the children collection
if 'children' in post_data and post_data['children'] is not None:
children = post_data['children']
store.update_children(location, children)
# cdodge: also commit any metadata which might have been passed along in the
# POST from the client, if it is there
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
# not presented to the end-user for editing. So let's fetch the original and
# 'apply' the submitted metadata, so we don't end up deleting system metadata
if post_data.get('metadata') is not None:
posted_metadata = post_data['metadata']
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
for metadata_key in posted_metadata.keys():
# let's strip out any metadata fields from the postback which have been identified as system metadata
# and therefore should not be user-editable, so we should accept them back from the client
if metadata_key in module.system_metadata_fields:
del posted_metadata[metadata_key]
elif posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if metadata_key in module.metadata:
del module.metadata[metadata_key]
del posted_metadata[metadata_key]
# overlay the new metadata over the modulestore sourced collection to support partial updates
module.metadata.update(posted_metadata)
# commit to datastore
store.update_metadata(location, module.metadata)

View File

@@ -0,0 +1,117 @@
from factory import Factory
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from time import gmtime
from uuid import uuid4
from xmodule.timeparse import stringify_time
def XMODULE_COURSE_CREATION(class_to_create, **kwargs):
return XModuleCourseFactory._create(class_to_create, **kwargs)
def XMODULE_ITEM_CREATION(class_to_create, **kwargs):
return XModuleItemFactory._create(class_to_create, **kwargs)
class XModuleCourseFactory(Factory):
"""
Factory for XModule courses.
"""
ABSTRACT_FACTORY = True
_creation_function = (XMODULE_COURSE_CREATION,)
@classmethod
def _create(cls, target_class, *args, **kwargs):
template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
org = kwargs.get('org')
number = kwargs.get('number')
display_name = kwargs.get('display_name')
location = Location('i4x', org, number,
'course', Location.clean(display_name))
store = modulestore('direct')
# Write the data to the mongo datastore
new_course = store.clone_item(template, location)
# This metadata code was copied from cms/djangoapps/contentstore/views.py
if display_name is not None:
new_course.metadata['display_name'] = display_name
new_course.metadata['data_dir'] = uuid4().hex
new_course.metadata['start'] = stringify_time(gmtime())
new_course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
{"type": "progress", "name": "Progress"}]
# Update the data in the mongo datastore
store.update_metadata(new_course.location.url(), new_course.own_metadata)
return new_course
class Course:
pass
class CourseFactory(XModuleCourseFactory):
FACTORY_FOR = Course
template = 'i4x://edx/templates/course/Empty'
org = 'MITx'
number = '999'
display_name = 'Robot Super Course'
class XModuleItemFactory(Factory):
"""
Factory for XModule items.
"""
ABSTRACT_FACTORY = True
_creation_function = (XMODULE_ITEM_CREATION,)
@classmethod
def _create(cls, target_class, *args, **kwargs):
"""
kwargs must include parent_location, template. Can contain display_name
target_class is ignored
"""
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
parent_location = Location(kwargs.get('parent_location'))
template = Location(kwargs.get('template'))
display_name = kwargs.get('display_name')
store = modulestore('direct')
# This code was based off that in cms/djangoapps/contentstore/views.py
parent = store.get_item(parent_location)
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
new_item = store.clone_item(template, dest_location)
# TODO: This needs to be deleted when we have proper storage for static content
new_item.metadata['data_dir'] = parent.metadata['data_dir']
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
new_item.metadata['display_name'] = display_name
store.update_metadata(new_item.location.url(), new_item.own_metadata)
if new_item.location.category not in DETACHED_CATEGORIES:
store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
return new_item
class Item:
pass
class ItemFactory(XModuleItemFactory):
FACTORY_FOR = Item
parent_location = 'i4x://MITx/999/course/Robot_Super_Course'
template = 'i4x://edx/templates/chapter/Empty'
display_name = 'Section One'

View File

@@ -0,0 +1,38 @@
from django.test.testcases import TestCase
from cache_toolbox.core import get_cached_content, set_cached_content, del_cached_content
from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent
class Content:
def __init__(self, location, content):
self.location = location
self.content = content
def get_id(self):
return StaticContent.get_id_from_location(self.location)
class CachingTestCase(TestCase):
# Tests for https://edx.lighthouseapp.com/projects/102637/tickets/112-updating-asset-does-not-refresh-the-cached-copy
unicodeLocation = Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters.jpg')
# Note that some of the parts are strings instead of unicode strings
nonUnicodeLocation = Location('c4x', u'mitX', u'800', 'thumbnail', 'monsters.jpg')
mockAsset = Content(unicodeLocation, 'my content')
def test_put_and_get(self):
set_cached_content(self.mockAsset)
self.assertEqual(self.mockAsset.content, get_cached_content(self.unicodeLocation).content,
'should be stored in cache with unicodeLocation')
self.assertEqual(self.mockAsset.content, get_cached_content(self.nonUnicodeLocation).content,
'should be stored in cache with nonUnicodeLocation')
def test_delete(self):
set_cached_content(self.mockAsset)
del_cached_content(self.nonUnicodeLocation)
self.assertEqual(None, get_cached_content(self.unicodeLocation),
'should not be stored in cache with unicodeLocation')
self.assertEqual(None, get_cached_content(self.nonUnicodeLocation),
'should not be stored in cache with nonUnicodeLocation')

View File

@@ -0,0 +1,275 @@
from django.test.testcases import TestCase
import datetime
import time
from django.contrib.auth.models import User
import xmodule
from django.test.client import Client
from django.core.urlresolvers import reverse
from xmodule.modulestore import Location
from cms.djangoapps.models.settings.course_details import CourseDetails,\
CourseSettingsEncoder
import json
from util import converters
import calendar
from util.converters import jsdate_to_time
from django.utils.timezone import UTC
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.contentstore.utils import get_modulestore
import copy
# YYYY-MM-DDThh:mm:ss.s+/-HH:MM
class ConvertersTestCase(TestCase):
@staticmethod
def struct_to_datetime(struct_time):
return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, struct_time.tm_mday, struct_time.tm_hour,
struct_time.tm_min, struct_time.tm_sec, tzinfo = UTC())
def compare_dates(self, date1, date2, expected_delta):
dt1 = ConvertersTestCase.struct_to_datetime(date1)
dt2 = ConvertersTestCase.struct_to_datetime(date2)
self.assertEqual(dt1 - dt2, expected_delta, str(date1) + "-" + str(date2) + "!=" + str(expected_delta))
def test_iso_to_struct(self):
self.compare_dates(converters.jsdate_to_time("2013-01-01"), converters.jsdate_to_time("2012-12-31"), datetime.timedelta(days=1))
self.compare_dates(converters.jsdate_to_time("2013-01-01T00"), converters.jsdate_to_time("2012-12-31T23"), datetime.timedelta(hours=1))
self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00"), converters.jsdate_to_time("2012-12-31T23:59"), datetime.timedelta(minutes=1))
self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00:00"), converters.jsdate_to_time("2012-12-31T23:59:59"), datetime.timedelta(seconds=1))
class CourseTestCase(TestCase):
def setUp(self):
uname = 'testuser'
email = 'test+courses@edx.org'
password = 'foo'
# Create the use so we can log them in.
self.user = User.objects.create_user(uname, email, password)
# Note that we do not actually need to do anything
# for registration if we directly mark them active.
self.user.is_active = True
# Staff has access to view all courses
self.user.is_staff = True
self.user.save()
# Flush and initialize the module store
# It needs the templates because it creates new records
# by cloning from the template.
# Note that if your test module gets in some weird state
# (though it shouldn't), do this manually
# from the bash shell to drop it:
# $ mongo test_xmodule --eval "db.dropDatabase()"
xmodule.modulestore.django._MODULESTORES = {}
xmodule.modulestore.django.modulestore().collection.drop()
xmodule.templates.update_templates()
self.client = Client()
self.client.login(username=uname, password=password)
self.course_data = {
'template': 'i4x://edx/templates/course/Empty',
'org': 'MITx',
'number': '999',
'display_name': 'Robot Super Course',
}
self.course_location = Location('i4x', 'MITx', '999', 'course', 'Robot_Super_Course')
self.create_course()
def tearDown(self):
xmodule.modulestore.django._MODULESTORES = {}
xmodule.modulestore.django.modulestore().collection.drop()
def create_course(self):
"""Create new course"""
self.client.post(reverse('create_new_course'), self.course_data)
class CourseDetailsTestCase(CourseTestCase):
def test_virgin_fetch(self):
details = CourseDetails.fetch(self.course_location)
self.assertEqual(details.course_location, self.course_location, "Location not copied into")
self.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))
self.assertIsNone(details.syllabus, "syllabus somehow initialized" + str(details.syllabus))
self.assertEqual(details.overview, "", "overview somehow initialized" + details.overview)
self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video))
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
def test_encoder(self):
details = CourseDetails.fetch(self.course_location)
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 ")
self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized")
self.assertEqual(jsondetails['overview'], "", "overview somehow initialized")
self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
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)
jsondetails.syllabus = "<a href='foo'>bar</a>"
# encode - decode to convert date fields and other data which changes form
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).syllabus,
jsondetails.syllabus, "After set syllabus")
jsondetails.overview = "Overview"
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).overview,
jsondetails.overview, "After set overview")
jsondetails.intro_video = "intro_video"
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).intro_video,
jsondetails.intro_video, "After set intro_video")
jsondetails.effort = "effort"
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).effort,
jsondetails.effort, "After set effort")
class CourseDetailsViewTest(CourseTestCase):
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
payload = copy.copy(details.__dict__)
payload['course_location'] = details.course_location.url()
payload['start_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.start_date)
payload['end_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.end_date)
payload['enrollment_start'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_start)
payload['enrollment_end'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_end)
resp = self.client.post(url, json.dumps(payload), "application/json")
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 test_update_and_fetch(self):
details = CourseDetails.fetch(self.course_location)
resp = self.client.get(reverse('course_settings', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
'name' : self.course_location.name }))
self.assertContains(resp, '<li><a href="#" class="is-shown" data-section="details">Course Details</a></li>', status_code=200, html=True)
# resp s/b json from here on
url = reverse('course_settings', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
'name' : self.course_location.name, 'section' : 'details' })
resp = self.client.get(url)
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get")
utc = UTC()
self.alter_field(url, details, 'start_date', datetime.datetime(2012,11,12,1,30, tzinfo=utc))
self.alter_field(url, details, 'start_date', datetime.datetime(2012,11,1,13,30, tzinfo=utc))
self.alter_field(url, details, 'end_date', datetime.datetime(2013,2,12,1,30, tzinfo=utc))
self.alter_field(url, details, 'enrollment_start', datetime.datetime(2012,10,12,1,30, tzinfo=utc))
self.alter_field(url, details, 'enrollment_end', datetime.datetime(2012,11,15,1,30, tzinfo=utc))
self.alter_field(url, details, 'overview', "Overview")
self.alter_field(url, details, 'intro_video', "intro_video")
self.alter_field(url, details, 'effort', "effort")
def compare_details_with_encoding(self, encoded, details, context):
self.compare_date_fields(details, encoded, context, 'start_date')
self.compare_date_fields(details, encoded, context, 'end_date')
self.compare_date_fields(details, encoded, context, 'enrollment_start')
self.compare_date_fields(details, encoded, context, 'enrollment_end')
self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==")
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
def compare_date_fields(self, details, encoded, context, field):
if details[field] is not None:
if field in encoded and encoded[field] is not None:
encoded_encoded = jsdate_to_time(encoded[field])
dt1 = ConvertersTestCase.struct_to_datetime(encoded_encoded)
if isinstance(details[field], datetime.datetime):
dt2 = details[field]
else:
details_encoded = jsdate_to_time(details[field])
dt2 = ConvertersTestCase.struct_to_datetime(details_encoded)
expected_delta = datetime.timedelta(0)
self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context)
else:
self.fail(field + " missing from encoded but in details at " + context)
elif field in encoded and encoded[field] is not None:
self.fail(field + " included in encoding but missing from details at " + context)
class CourseGradingTest(CourseTestCase):
def test_initial_grader(self):
descriptor = get_modulestore(self.course_location).get_item(self.course_location)
test_grader = CourseGradingModel(descriptor)
# ??? How much should this test bake in expectations about defaults and thus fail if defaults change?
self.assertEqual(self.course_location, test_grader.course_location, "Course locations")
self.assertIsNotNone(test_grader.graders, "No graders")
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
def test_fetch_grader(self):
test_grader = CourseGradingModel.fetch(self.course_location.url())
self.assertEqual(self.course_location, test_grader.course_location, "Course locations")
self.assertIsNotNone(test_grader.graders, "No graders")
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
test_grader = CourseGradingModel.fetch(self.course_location)
self.assertEqual(self.course_location, test_grader.course_location, "Course locations")
self.assertIsNotNone(test_grader.graders, "No graders")
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
for i, grader in enumerate(test_grader.graders):
subgrader = CourseGradingModel.fetch_grader(self.course_location, i)
self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal")
subgrader = CourseGradingModel.fetch_grader(self.course_location.list(), 0)
self.assertDictEqual(test_grader.graders[0], subgrader, "failed with location as list")
def test_fetch_cutoffs(self):
test_grader = CourseGradingModel.fetch_cutoffs(self.course_location)
# ??? should this check that it's at least a dict? (expected is { "pass" : 0.5 } I think)
self.assertIsNotNone(test_grader, "No cutoffs via fetch")
test_grader = CourseGradingModel.fetch_cutoffs(self.course_location.url())
self.assertIsNotNone(test_grader, "No cutoffs via fetch with url")
def test_fetch_grace(self):
test_grader = CourseGradingModel.fetch_grace_period(self.course_location)
# almost a worthless test
self.assertIn('grace_period', test_grader, "No grace via fetch")
test_grader = CourseGradingModel.fetch_grace_period(self.course_location.url())
self.assertIn('grace_period', test_grader, "No cutoffs via fetch with url")
def test_update_from_json(self):
test_grader = CourseGradingModel.fetch(self.course_location)
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update")
test_grader.graders[0]['weight'] = test_grader.graders[0].get('weight') * 2
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2")
test_grader.grade_cutoffs['D'] = 0.3
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D")
test_grader.grace_period = {'hours' : '4'}
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period")
def test_update_grader_from_json(self):
test_grader = CourseGradingModel.fetch(self.course_location)
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update")
test_grader.graders[1]['min_count'] = test_grader.graders[1].get('min_count') + 2
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2")
test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")

View File

@@ -0,0 +1,30 @@
from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase
from django.core.urlresolvers import reverse
import json
class CourseUpdateTest(CourseTestCase):
def test_course_update(self):
# first get the update to force the creation
url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
'name' : self.course_location.name })
self.client.get(url)
content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0"></iframe>'
payload = { 'content' : content,
'date' : 'January 8, 2013'}
url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
'provided_id' : ''})
resp = self.client.post(url, json.dumps(payload), "application/json")
payload= json.loads(resp.content)
self.assertHTMLEqual(content, payload['content'], "single iframe")
url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
'provided_id' : payload['id']})
content += '<div>div <p>p</p></div>'
payload['content'] = content
resp = self.client.post(url, json.dumps(payload), "application/json")
self.assertHTMLEqual(content, json.loads(resp.content)['content'], "iframe w/ div")

View File

@@ -0,0 +1,18 @@
from django.test.testcases import TestCase
from cms.djangoapps.contentstore import utils
import mock
class LMSLinksTestCase(TestCase):
def about_page_test(self):
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")
def ls_link_test(self):
location = 'i4x','mitX','101','vertical', 'contacting_us'
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
link = utils.get_lms_link_for_item(location, False)
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
link = utils.get_lms_link_for_item(location, True)
self.assertEquals(link, "//preview.localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")

View File

@@ -1,20 +1,29 @@
import json
import shutil
from django.test import TestCase
from django.test.client import Client
from mock import patch, Mock
from override_settings import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse
from path import path
from tempfile import mkdtemp
import json
from student.models import Registration
from django.contrib.auth.models import User
from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django
from xmodule.modulestore import Location
from xmodule.modulestore.xml_importer import import_from_xml
import copy
from factories import *
from xmodule.modulestore.store_utilities import clone_course
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.xml_exporter import export_to_xml
from cms.djangoapps.contentstore.utils import get_modulestore
from xmodule.capa_module import CapaDescriptor
def parse_json(response):
"""Parse response, which is assumed to be json"""
@@ -22,33 +31,33 @@ def parse_json(response):
def user(email):
'''look up a user by email'''
"""look up a user by email"""
return User.objects.get(email=email)
def registration(email):
'''look up registration object by email'''
"""look up registration object by email"""
return Registration.objects.get(user__email=email)
class ContentStoreTestCase(TestCase):
def _login(self, email, pw):
'''Login. View should always return 200. The success/fail is in the
returned json'''
"""Login. View should always return 200. The success/fail is in the
returned json"""
resp = self.client.post(reverse('login_post'),
{'email': email, 'password': pw})
self.assertEqual(resp.status_code, 200)
return resp
def login(self, email, pw):
'''Login, check that it worked.'''
"""Login, check that it worked."""
resp = self._login(email, pw)
data = parse_json(resp)
self.assertTrue(data['success'])
return resp
def _create_account(self, username, email, pw):
'''Try to create an account. No error checking'''
"""Try to create an account. No error checking"""
resp = self.client.post('/create_account', {
'username': username,
'email': email,
@@ -62,7 +71,7 @@ class ContentStoreTestCase(TestCase):
return resp
def create_account(self, username, email, pw):
'''Create the account and check that it worked'''
"""Create the account and check that it worked"""
resp = self._create_account(username, email, pw)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
@@ -74,8 +83,8 @@ class ContentStoreTestCase(TestCase):
return resp
def _activate_user(self, email):
'''Look up the activation key for the user, then hit the activate view.
No error checking'''
"""Look up the activation key for the user, then hit the activate view.
No error checking"""
activation_key = registration(email).activation_key
# and now we try to activate
@@ -141,8 +150,6 @@ class AuthTestCase(ContentStoreTestCase):
"""Make sure pages that do require login work."""
auth_pages = (
reverse('index'),
reverse('edit_item'),
reverse('save_item'),
)
# These are pages that should just load when the user is logged in
@@ -181,31 +188,291 @@ class AuthTestCase(ContentStoreTestCase):
TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE)
TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data')
TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data')
@override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
class EditTestCase(ContentStoreTestCase):
"""Check that editing functionality works on example courses"""
class ContentStoreTest(TestCase):
def setUp(self):
email = 'edit@test.com'
uname = 'testuser'
email = 'test+courses@edx.org'
password = 'foo'
self.create_account('edittest', email, password)
self.activate_user(email)
self.login(email, password)
# Create the use so we can log them in.
self.user = User.objects.create_user(uname, email, password)
# Note that we do not actually need to do anything
# for registration if we directly mark them active.
self.user.is_active = True
# Staff has access to view all courses
self.user.is_staff = True
self.user.save()
# Flush and initialize the module store
# It needs the templates because it creates new records
# by cloning from the template.
# Note that if your test module gets in some weird state
# (though it shouldn't), do this manually
# from the bash shell to drop it:
# $ mongo test_xmodule --eval "db.dropDatabase()"
xmodule.modulestore.django._MODULESTORES = {}
xmodule.modulestore.django.modulestore().collection.drop()
xmodule.templates.update_templates()
self.client = Client()
self.client.login(username=uname, password=password)
self.course_data = {
'template': 'i4x://edx/templates/course/Empty',
'org': 'MITx',
'number': '999',
'display_name': 'Robot Super Course',
}
def tearDown(self):
# Make sure you flush out the test modulestore after the end
# of the last test because otherwise on the next run
# cms/djangoapps/contentstore/__init__.py
# update_templates() will try to update the templates
# via upsert and it sometimes seems to be messing things up.
xmodule.modulestore.django._MODULESTORES = {}
xmodule.modulestore.django.modulestore().collection.drop()
def check_edit_item(self, test_course_name):
def test_create_course(self):
"""Test new course creation - happy path"""
resp = self.client.post(reverse('create_new_course'), self.course_data)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
def test_create_course_duplicate_course(self):
"""Test new course creation - error path"""
resp = self.client.post(reverse('create_new_course'), self.course_data)
resp = self.client.post(reverse('create_new_course'), self.course_data)
data = parse_json(resp)
self.assertEqual(resp.status_code, 200)
self.assertEqual(data['ErrMsg'], 'There is already a course defined with this name.')
def test_create_course_duplicate_number(self):
"""Test new course creation - error path"""
resp = self.client.post(reverse('create_new_course'), self.course_data)
self.course_data['display_name'] = 'Robot Super Course Two'
resp = self.client.post(reverse('create_new_course'), self.course_data)
data = parse_json(resp)
self.assertEqual(resp.status_code, 200)
self.assertEqual(data['ErrMsg'],
'There is already a course defined with the same organization and course number.')
def test_create_course_with_bad_organization(self):
"""Test new course creation - error path for bad organization name"""
self.course_data['org'] = 'University of California, Berkeley'
resp = self.client.post(reverse('create_new_course'), self.course_data)
data = parse_json(resp)
self.assertEqual(resp.status_code, 200)
self.assertEqual(data['ErrMsg'],
"Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.")
def test_course_index_view_with_no_courses(self):
"""Test viewing the index page with no courses"""
# Create a course so there is something to view
resp = self.client.get(reverse('index'))
self.assertContains(resp,
'<h1>My Courses</h1>',
status_code=200,
html=True)
def test_course_factory(self):
course = CourseFactory.create()
self.assertIsInstance(course, xmodule.course_module.CourseDescriptor)
def test_item_factory(self):
course = CourseFactory.create()
item = ItemFactory.create(parent_location=course.location)
self.assertIsInstance(item, xmodule.seq_module.SequenceDescriptor)
def test_course_index_view_with_course(self):
"""Test viewing the index page with an existing course"""
CourseFactory.create(display_name='Robot Super Educational Course')
resp = self.client.get(reverse('index'))
self.assertContains(resp,
'<span class="class-name">Robot Super Educational Course</span>',
status_code=200,
html=True)
def test_course_overview_view_with_course(self):
"""Test viewing the course overview page with an existing course"""
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
data = {
'org': 'MITx',
'course': '999',
'name': Location.clean('Robot Super Course'),
}
resp = self.client.get(reverse('course_index', kwargs=data))
self.assertContains(resp,
'<a href="/MITx/999/course/Robot_Super_Course" class="class-name">Robot Super Course</a>',
status_code=200,
html=True)
def test_clone_item(self):
"""Test cloning an item. E.g. creating a new section"""
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
section_data = {
'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course',
'template' : 'i4x://edx/templates/chapter/Empty',
'display_name': 'Section One',
}
resp = self.client.post(reverse('clone_item'), section_data)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
self.assertRegexpMatches(data['id'],
'^i4x:\/\/MITx\/999\/chapter\/([0-9]|[a-f]){32}$')
def check_edit_unit(self, test_course_name):
import_from_xml(modulestore(), 'common/test/data/', [test_course_name])
for descriptor in modulestore().get_items(Location(None, None, None, None, None)):
for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)):
print "Checking ", descriptor.location.url()
print descriptor.__class__, descriptor.location
resp = self.client.get(reverse('edit_item'), {'id': descriptor.location.url()})
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
self.assertEqual(resp.status_code, 200)
def test_edit_item_toy(self):
self.check_edit_item('toy')
def test_edit_unit_toy(self):
self.check_edit_unit('toy')
def test_edit_item_full(self):
self.check_edit_item('full')
def test_edit_unit_full(self):
self.check_edit_unit('full')
def test_about_overrides(self):
'''
This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html
while there is a base definition in /about/effort.html
'''
import_from_xml(modulestore(), 'common/test/data/', ['full'])
ms = modulestore('direct')
effort = ms.get_item(Location(['i4x','edX','full','about','effort', None]))
self.assertEqual(effort.definition['data'],'6 hours')
# this one should be in a non-override folder
effort = ms.get_item(Location(['i4x','edX','full','about','end_date', None]))
self.assertEqual(effort.definition['data'],'TBD')
def test_clone_course(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
resp = self.client.post(reverse('create_new_course'), self.course_data)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
ms = modulestore('direct')
cs = contentstore()
source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course')
clone_course(ms, cs, source_location, dest_location)
# now loop through all the units in the course and verify that the clone can render them, which
# means the objects are at least present
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
self.assertGreater(len(items), 0)
clone_items = ms.get_items(Location(['i4x', 'MITx','999','vertical', None]))
self.assertGreater(len(clone_items), 0)
for descriptor in items:
new_loc = descriptor.location._replace(org = 'MITx', course='999')
print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url())
resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()}))
self.assertEqual(resp.status_code, 200)
def test_delete_course(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
ms = modulestore('direct')
cs = contentstore()
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
delete_course(ms, cs, location)
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
self.assertEqual(len(items), 0)
def test_export_course(self):
ms = modulestore('direct')
cs = contentstore()
import_from_xml(ms, 'common/test/data/', ['full'])
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
root_dir = path(mkdtemp())
print 'Exporting to tempdir = {0}'.format(root_dir)
# export out to a tempdir
export_to_xml(ms, cs, location, root_dir, 'test_export')
# remove old course
delete_course(ms, cs, location)
# reimport
import_from_xml(ms, root_dir, ['test_export'])
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
self.assertGreater(len(items), 0)
for descriptor in items:
print "Checking {0}....".format(descriptor.location.url())
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
self.assertEqual(resp.status_code, 200)
shutil.rmtree(root_dir)
def test_course_handouts_rewrites(self):
ms = modulestore('direct')
cs = contentstore()
# import a test course
import_from_xml(ms, 'common/test/data/', ['full'])
handout_location= Location(['i4x', 'edX', 'full', 'course_info', 'handouts'])
# get module info
resp = self.client.get(reverse('module_info', kwargs={'module_location': handout_location}))
# make sure we got a successful response
self.assertEqual(resp.status_code, 200)
# check that /static/ has been converted to the full path
# note, we know the link it should be because that's what in the 'full' course in the test data
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
def test_capa_module(self):
"""Test that a problem treats markdown specially."""
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
problem_data = {
'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course',
'template' : 'i4x://edx/templates/problem/Empty'
}
resp = self.client.post(reverse('clone_item'), problem_data)
self.assertEqual(resp.status_code, 200)
payload = parse_json(resp)
problem_loc = payload['id']
problem = get_modulestore(problem_loc).get_item(problem_loc)
# should be a CapaDescriptor
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
context = problem.get_context()
self.assertIn('markdown', context, "markdown is missing from context")
self.assertIn('markdown', problem.metadata, "markdown is missing from metadata")
self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields")

View File

@@ -1,13 +1,29 @@
from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
def get_modulestore(location):
"""
Returns the correct modulestore to use for modifying the specified location
"""
if not isinstance(location, Location):
location = Location(location)
if location.category in DIRECT_ONLY_CATEGORIES:
return modulestore('direct')
else:
return modulestore()
'''
cdodge: for a given Xmodule, return the course that it belongs to
NOTE: This makes a lot of assumptions about the format of the course location
Also we have to assert that this module maps to only one course item - it'll throw an
assert if not
'''
def get_course_location_for_item(location):
'''
cdodge: for a given Xmodule, return the course that it belongs to
NOTE: This makes a lot of assumptions about the format of the course location
Also we have to assert that this module maps to only one course item - it'll throw an
assert if not
'''
item_loc = Location(location)
# check to see if item is already a course, if so we can skip this
@@ -24,8 +40,106 @@ def get_course_location_for_item(location):
raise BaseException('Could not find course at {0}'.format(course_search_location))
if found_cnt > 1:
raise BaseException('Found more than one course at {0}. There should only be one!!!'.format(course_search_location))
raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
location = courses[0].location
return location
def get_course_for_item(location):
'''
cdodge: for a given Xmodule, return the course that it belongs to
NOTE: This makes a lot of assumptions about the format of the course location
Also we have to assert that this module maps to only one course item - it'll throw an
assert if not
'''
item_loc = Location(location)
# @hack! We need to find the course location however, we don't
# know the 'name' parameter in this context, so we have
# to assume there's only one item in this query even though we are not specifying a name
course_search_location = ['i4x', item_loc.org, item_loc.course, 'course', None]
courses = modulestore().get_items(course_search_location)
# make sure we found exactly one match on this above course search
found_cnt = len(courses)
if found_cnt == 0:
raise BaseException('Could not find course at {0}'.format(course_search_location))
if found_cnt > 1:
raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
return courses[0]
def get_lms_link_for_item(location, preview=False):
if settings.LMS_BASE is not None:
lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format(
preview='preview.' if preview else '',
lms_base=settings.LMS_BASE,
course_id=get_course_id(location),
location=Location(location)
)
else:
lms_link = None
return lms_link
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,
course_id=get_course_id(location)
)
else:
lms_link = None
return lms_link
def get_course_id(location):
"""
Returns the course_id from a given the location tuple.
"""
# TODO: These will need to be changed to point to the particular instance of this problem in the particular course
return modulestore().get_containing_courses(Location(location))[0].id
class UnitState(object):
draft = 'draft'
private = 'private'
public = 'public'
def compute_unit_state(unit):
"""
Returns whether this unit is 'draft', 'public', or 'private'.
'draft' content is in the process of being edited, but still has a previous
version visible in the LMS
'public' content is locked and visible in the LMS
'private' content is editabled and not visible in the LMS
"""
if unit.metadata.get('is_draft', False):
try:
modulestore('direct').get_item(unit.location)
return UnitState.draft
except ItemNotFoundError:
return UnitState.private
else:
return UnitState.public
def get_date_display(date):
return date.strftime("%d %B, %Y at %I:%M %p")
def update_item(location, value):
"""
If value is None, delete the db entry. Otherwise, update it using the correct modulestore.
"""
if value is None:
get_modulestore(location).delete_item(location)
else:
get_modulestore(location).update_item(location, value)

View File

@@ -1,12 +1,19 @@
from util.json_request import expect_json
import json
import os
import logging
import os
import sys
import mimetypes
import StringIO
import exceptions
import time
import tarfile
import shutil
from datetime import datetime
from collections import defaultdict
from uuid import uuid4
from path import path
from xmodule.modulestore.xml_exporter import export_to_xml
from tempfile import mkdtemp
from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image
@@ -18,35 +25,52 @@ from django.core.context_processors import csrf
from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse
from django.conf import settings
from django import forms
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.x_module import ModuleSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from github_sync import export_to_github
from static_replace import replace_urls
from external_auth.views import ssl_login_shortcut
from mitxmako.shortcuts import render_to_response, render_to_string
from xmodule.modulestore.django import modulestore
from xmodule_modifiers import replace_static_urls, wrap_xmodule
from xmodule.exceptions import NotFoundError
from functools import partial
from itertools import groupby
from operator import attrgetter
from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
from cache_toolbox.core import set_cached_content, get_cached_content, del_cached_content
from auth.authz import is_user_in_course_group_role, get_users_in_course_group_by_role
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
from auth.authz import ADMIN_ROLE_NAME, EDITOR_ROLE_NAME
from .utils import get_course_location_for_item
from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState, get_course_for_item
from xmodule.modulestore.xml_importer import import_from_xml
from contentstore.course_info_model import get_course_updates,\
update_course_updates, delete_course_update
from cache_toolbox.core import del_cached_content
from xmodule.timeparse import stringify_time
from contentstore.module_info_model import get_module_info, set_module_info
from cms.djangoapps.models.settings.course_details import CourseDetails,\
CourseSettingsEncoder
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.contentstore.utils import get_modulestore
from lxml import etree
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
log = logging.getLogger(__name__)
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
# ==== Public views ==================================================
@ensure_csrf_cookie
@@ -57,14 +81,17 @@ def signup(request):
csrf_token = csrf(request)['csrf_token']
return render_to_response('signup.html', {'csrf': csrf_token})
@ssl_login_shortcut
@ensure_csrf_cookie
def login_page(request):
"""
Display the login form.
"""
csrf_token = csrf(request)['csrf_token']
return render_to_response('login.html', {'csrf': csrf_token})
return render_to_response('login.html', {
'csrf': csrf_token,
'forgot_password_link': "//{base}/#forgot-password-modal".format(base=settings.LMS_BASE),
})
# ==== Views for any logged-in user ==================================
@@ -77,25 +104,45 @@ def index(request):
"""
courses = modulestore().get_items(['i4x', None, None, 'course', None])
# filter out courses that we don't have access to
courses = filter(lambda course: has_access(request.user, course.location), courses)
# filter out courses that we don't have access too
def course_filter(course):
return (has_access(request.user, course.location)
and course.location.course != 'templates'
and course.location.org != ''
and course.location.course != ''
and course.location.name != '')
courses = filter(course_filter, courses)
return render_to_response('index.html', {
'new_course_template' : Location('i4x', 'edx', 'templates', 'course', 'Empty'),
'courses': [(course.metadata.get('display_name'),
reverse('course_index', args=[
course.location.org,
course.location.course,
course.location.name]))
for course in courses]
for course in courses],
'user': request.user
})
# ==== Views with per-item permissions================================
def has_access(user, location, role=EDITOR_ROLE_NAME):
'''Return True if user allowed to access this piece of data'''
'''Note that the CMS permissions model is with respect to courses'''
return is_user_in_course_group_role(user, get_course_location_for_item(location), role)
def has_access(user, location, role=STAFF_ROLE_NAME):
'''
Return True if user allowed to access this piece of data
Note that the CMS permissions model is with respect to courses
There is a super-admin permissions if user.is_staff is set
Also, since we're unifying the user database between LMS and CAS,
I'm presuming that the course instructor (formally known as admin)
will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR
has all the rights that STAFF do
'''
course_location = get_course_location_for_item(location)
_has_access = is_user_in_course_group_role(user, course_location, role)
# if we're not in STAFF, perhaps we're in INSTRUCTOR groups
if not _has_access and role == STAFF_ROLE_NAME:
_has_access = is_user_in_course_group_role(user, course_location, INSTRUCTOR_ROLE_NAME)
return _has_access
@login_required
@@ -112,31 +159,88 @@ def course_index(request, org, course, name):
if not has_access(request.user, location):
raise PermissionDenied()
# TODO (cpennington): These need to be read in from the active user
_course = modulestore().get_item(location)
weeks = _course.get_children()
#upload_asset_callback_url = "/{org}/{course}/course/{name}/upload_asset".format(
# org = org,
# course = course,
# name = name
# )
upload_asset_callback_url = reverse('upload_asset', kwargs = {
'org' : org,
'course' : course,
'coursename' : name
})
logging.debug(upload_asset_callback_url)
return render_to_response('course_index.html', {
'weeks': weeks,
'upload_asset_callback_url': upload_asset_callback_url
})
course = modulestore().get_item(location)
sections = course.get_children()
return render_to_response('overview.html', {
'active_tab': 'courseware',
'context_course': course,
'sections': sections,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
'parent_location': course.location,
'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'),
'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point...
'upload_asset_callback_url': upload_asset_callback_url,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty')
})
@login_required
def edit_item(request):
def edit_subsection(request, location):
# check that we have permissions to edit this item
if not has_access(request.user, location):
raise PermissionDenied()
item = modulestore().get_item(location)
# TODO: we need a smarter way to figure out what course an item is in
for course in modulestore().get_courses():
if (course.location.org == item.location.org and
course.location.course == item.location.course):
break
lms_link = get_lms_link_for_item(location)
preview_link = get_lms_link_for_item(location, preview=True)
# make sure that location references a 'sequential', otherwise return BadRequest
if item.location.category != 'sequential':
return HttpResponseBadRequest()
parent_locs = modulestore().get_parent_locations(location, None)
# we're for now assuming a single parent
if len(parent_locs) != 1:
logging.error('Multiple (or none) parents have been found for {0}'.format(location))
# this should blow up if we don't find any parents, which would be erroneous
parent = modulestore().get_item(parent_locs[0])
# remove all metadata from the generic dictionary that is presented in a more normalized UI
policy_metadata = dict((key,value) for key, value in item.metadata.iteritems()
if key not in ['display_name', 'start', 'due', 'format'] and key not in item.system_metadata_fields)
can_view_live = False
subsection_units = item.get_children()
for unit in subsection_units:
state = compute_unit_state(unit)
if state == UnitState.public or state == UnitState.draft:
can_view_live = True
break
return render_to_response('edit_subsection.html',
{'subsection': item,
'context_course': course,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'lms_link': lms_link,
'preview_link': preview_link,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
'parent_location': course.location,
'parent_item': parent,
'policy_metadata' : policy_metadata,
'subsection_units' : subsection_units,
'can_view_live' : can_view_live
})
@login_required
def edit_unit(request, location):
"""
Display an editing page for the specified module.
@@ -144,65 +248,125 @@ def edit_item(request):
id: A Location URL
"""
item_location = request.GET['id']
# check that we have permissions to edit this item
if not has_access(request.user, item_location):
if not has_access(request.user, location):
raise PermissionDenied()
item = modulestore().get_item(item_location)
item.get_html = wrap_xmodule(item.get_html, item, "xmodule_edit.html")
item = modulestore().get_item(location)
if settings.LMS_BASE is not None:
lms_link = "{lms_base}/courses/{course_id}/jump_to/{location}".format(
lms_base=settings.LMS_BASE,
# TODO: These will need to be changed to point to the particular instance of this problem in the particular course
course_id= modulestore().get_containing_courses(item.location)[0].id,
location=item.location,
)
else:
lms_link = None
# TODO: we need a smarter way to figure out what course an item is in
for course in modulestore().get_courses():
if (course.location.org == item.location.org and
course.location.course == item.location.course):
break
lms_link = get_lms_link_for_item(item.location)
preview_lms_link = get_lms_link_for_item(item.location, preview=True)
component_templates = defaultdict(list)
templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
for template in templates:
if template.location.category in COMPONENT_TYPES:
component_templates[template.location.category].append((
template.display_name,
template.location.url(),
'markdown' in template.metadata,
template.location.name == 'Empty'
))
components = [
component.location.url()
for component
in item.get_children()
]
# TODO (cpennington): If we share units between courses,
# this will need to change to check permissions correctly so as
# to pick the correct parent subsection
containing_subsection_locs = modulestore().get_parent_locations(location, None)
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
containing_section_locs = modulestore().get_parent_locations(containing_subsection.location, None)
containing_section = modulestore().get_item(containing_section_locs[0])
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
# so let's generate the link url here
# need to figure out where this item is in the list of children as the preview will need this
index =1
for child in containing_subsection.get_children():
if child.location == item.location:
break
index = index + 1
preview_lms_link = '//{preview}{lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
preview='preview.',
lms_base=settings.LMS_BASE,
org=course.location.org,
course=course.location.course,
course_name=course.location.name,
section=containing_section.location.name,
subsection=containing_subsection.location.name,
index=index)
unit_state = compute_unit_state(item)
try:
published_date = time.strftime('%B %d, %Y', item.metadata.get('published_date'))
except TypeError:
published_date = None
return render_to_response('unit.html', {
'contents': item.get_html(),
'js_module': item.js_module_name,
'category': item.category,
'url_name': item.url_name,
'previews': get_module_previews(request, item),
'metadata': item.metadata,
# TODO: It would be nice to able to use reverse here in some form, but we don't have the lms urls imported
'lms_link': lms_link,
'context_course': course,
'active_tab': 'courseware',
'unit': item,
'unit_location': location,
'components': components,
'component_templates': component_templates,
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
'subsection': containing_subsection,
'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.start))) if containing_subsection.start is not None else None,
'section': containing_section,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'unit_state': unit_state,
'published_date': published_date,
})
@login_required
def new_item(request):
"""
Display a page where the user can create a new item from a template
def preview_component(request, location):
# TODO (vshnayder): change name from id to location in coffee+html as well.
if not has_access(request.user, location):
raise HttpResponseForbidden()
Expects a GET request with the parameter 'parent_location', which is the element to add
the newly created item to as a child.
component = modulestore().get_item(location)
parent_location: A Location URL
"""
parent_location = request.GET['parent_location']
if not has_access(request.user, parent_location):
raise Http404
parent = modulestore().get_item(parent_location)
templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
templates.sort(key=attrgetter('location.category', 'display_name'))
return render_to_response('new_item.html', {
'parent_name': parent.display_name,
'parent_location': parent.location.url(),
'templates': groupby(templates, attrgetter('location.category')),
return render_to_response('component.html', {
'preview': get_module_previews(request, component)[0],
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
})
@expect_json
@login_required
@ensure_csrf_cookie
def assignment_type_update(request, org, course, category, name):
'''
CRUD operations on assignment types for sections and subsections and anything else gradable.
'''
location = Location(['i4x', org, course, category, name])
if not has_access(request.user, location):
raise HttpResponseForbidden()
if request.method == 'GET':
return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)),
mimetype="application/json")
elif request.method == 'POST': # post or put, doesn't matter.
return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)),
mimetype="application/json")
def user_author_string(user):
'''Get an author string for commits by this user. Format:
@@ -277,8 +441,12 @@ def save_preview_state(request, preview_id, location, instance_state, shared_sta
if 'preview_states' not in request.session:
request.session['preview_states'] = defaultdict(dict)
request.session['preview_states'][preview_id, location]['instance'] = instance_state
request.session['preview_states'][preview_id, location]['shared'] = shared_state
# request.session doesn't notice indirect changes; so, must set its dict w/ every change to get
# it to persist: http://www.djangobook.com/en/2.0/chapter14.html
preview_states = request.session['preview_states']
preview_states[preview_id, location]['instance'] = instance_state
preview_states[preview_id, location]['shared'] = shared_state
request.session['preview_states'] = preview_states # make session mgmt notice the update
def render_from_lms(template_name, dictionary, context=None, namespace='main'):
@@ -297,6 +465,7 @@ def preview_module_system(request, preview_id, descriptor):
preview_id (str): An identifier specifying which preview this module is used for
descriptor: An XModuleDescriptor
"""
return ModuleSystem(
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
@@ -310,7 +479,7 @@ def preview_module_system(request, preview_id, descriptor):
)
def get_preview_module(request, preview_id, location):
def get_preview_module(request, preview_id, descriptor):
"""
Returns a preview XModule at the specified location. The preview_data is chosen arbitrarily
from the set of preview data for the descriptor specified by Location
@@ -319,7 +488,6 @@ def get_preview_module(request, preview_id, location):
preview_id (str): An identifier specifying which preview this module is used for
location: A Location
"""
descriptor = modulestore().get_item(location)
instance_state, shared_state = descriptor.get_sample_state()[0]
return load_preview_module(request, preview_id, descriptor, instance_state, shared_state)
@@ -343,9 +511,24 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
error_msg=exc_info_to_str(sys.exc_info())
).xmodule_constructor(system)(None, None)
# cdodge: Special case
if module.location.category == 'static_tab':
module.get_html = wrap_xmodule(
module.get_html,
module,
"xmodule_tab_display.html",
)
else:
module.get_html = wrap_xmodule(
module.get_html,
module,
"xmodule_display.html",
)
module.get_html = replace_static_urls(
wrap_xmodule(module.get_html, module, "xmodule_display.html"),
module.metadata.get('data_dir', module.location.course)
module.get_html,
module.metadata.get('data_dir', module.location.course),
course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None])
)
save_preview_state(request, preview_id, descriptor.location.url(),
module.get_instance_state(), module.get_shared_state())
@@ -367,6 +550,51 @@ def get_module_previews(request, descriptor):
return preview_html
def _xmodule_recurse(item, action):
for child in item.get_children():
_xmodule_recurse(child, action)
action(item)
@login_required
@expect_json
def delete_item(request):
item_location = request.POST['id']
item_loc = Location(item_location)
# check permissions for this user within this course
if not has_access(request.user, item_location):
raise PermissionDenied()
# optional parameter to delete all children (default False)
delete_children = request.POST.get('delete_children', False)
delete_all_versions = request.POST.get('delete_all_versions', False)
item = modulestore().get_item(item_location)
store = get_modulestore(item_loc)
# @TODO: this probably leaves draft items dangling. My preferance would be for the semantic to be
# if item.location.revision=None, then delete both draft and published version
# if caller wants to only delete the draft than the caller should put item.location.revision='draft'
if delete_children:
_xmodule_recurse(item, lambda i: store.delete_item(i.location))
else:
store.delete_item(item.location)
# cdodge: this is a bit of a hack until I can talk with Cale about the
# semantics of delete_item whereby the store is draft aware. Right now calling
# delete_item on a vertical tries to delete the draft version leaving the
# requested delete to never occur
if item.location.revision is None and item.location.category=='vertical' and delete_all_versions:
modulestore('direct').delete_item(item.location)
return HttpResponse()
@login_required
@expect_json
def save_item(request):
@@ -376,75 +604,133 @@ def save_item(request):
if not has_access(request.user, item_location):
raise PermissionDenied()
if request.POST['data']:
store = get_modulestore(Location(item_location));
if request.POST.get('data') is not None:
data = request.POST['data']
modulestore().update_item(item_location, data)
if request.POST['children']:
store.update_item(item_location, data)
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
# deleting the children object from the children collection
if 'children' in request.POST and request.POST['children'] is not None:
children = request.POST['children']
modulestore().update_children(item_location, children)
store.update_children(item_location, children)
# cdodge: also commit any metadata which might have been passed along in the
# POST from the client, if it is there
# note, that the postback is not the complete metadata, as there's system metadata which is
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
# not presented to the end-user for editing. So let's fetch the original and
# 'apply' the submitted metadata, so we don't end up deleting system metadata
if request.POST['metadata']:
if request.POST.get('metadata') is not None:
posted_metadata = request.POST['metadata']
# fetch original
existing_item = modulestore().get_item(item_location)
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
for metadata_key in posted_metadata.keys():
# let's strip out any metadata fields from the postback which have been identified as system metadata
# and therefore should not be user-editable, so we should accept them back from the client
if metadata_key in existing_item.system_metadata_fields:
del posted_metadata[metadata_key]
elif posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if metadata_key in existing_item.metadata:
del existing_item.metadata[metadata_key]
del posted_metadata[metadata_key]
# overlay the new metadata over the modulestore sourced collection to support partial updates
existing_item.metadata.update(posted_metadata)
modulestore().update_metadata(item_location, existing_item.metadata)
# Export the course back to github
# This uses wildcarding to find the course, which requires handling
# multiple courses returned, but there should only ever be one
course_location = Location(item_location)._replace(
category='course', name=None)
courses = modulestore().get_items(course_location, depth=None)
for course in courses:
author_string = user_author_string(request.user)
export_to_github(course, "CMS Edit", author_string)
# commit to datastore
store.update_metadata(item_location, existing_item.metadata)
descriptor = modulestore().get_item(item_location)
preview_html = get_module_previews(request, descriptor)
return HttpResponse()
return HttpResponse(json.dumps(preview_html))
@login_required
@expect_json
def create_draft(request):
location = request.POST['id']
# check permissions for this user within this course
if not has_access(request.user, location):
raise PermissionDenied()
# This clones the existing item location to a draft location (the draft is implicit,
# because modulestore is a Draft modulestore)
modulestore().clone_item(location, location)
return HttpResponse()
@login_required
@expect_json
def publish_draft(request):
location = request.POST['id']
# check permissions for this user within this course
if not has_access(request.user, location):
raise PermissionDenied()
item = modulestore().get_item(location)
_xmodule_recurse(item, lambda i: modulestore().publish(i.location, request.user.id))
return HttpResponse()
@login_required
@expect_json
def unpublish_unit(request):
location = request.POST['id']
# check permissions for this user within this course
if not has_access(request.user, location):
raise PermissionDenied()
item = modulestore().get_item(location)
_xmodule_recurse(item, lambda i: modulestore().unpublish(i.location))
return HttpResponse()
@login_required
@expect_json
def clone_item(request):
parent_location = Location(request.POST['parent_location'])
template = Location(request.POST['template'])
display_name = request.POST['name']
display_name = request.POST.get('display_name')
if not has_access(request.user, parent_location):
raise PermissionDenied()
parent = modulestore().get_item(parent_location)
dest_location = parent_location._replace(category=template.category, name=Location.clean_for_url_name(display_name))
parent = get_modulestore(template).get_item(parent_location)
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
new_item = modulestore().clone_item(template, dest_location)
new_item.metadata['display_name'] = display_name
new_item = get_modulestore(template).clone_item(template, dest_location)
# TODO: This needs to be deleted when we have proper storage for static content
new_item.metadata['data_dir'] = parent.metadata['data_dir']
modulestore().update_metadata(new_item.location.url(), new_item.own_metadata)
modulestore().update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
new_item.metadata['display_name'] = display_name
return HttpResponse()
get_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata)
if new_item.location.category not in DETACHED_CATEGORIES:
get_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))
'''
cdodge: this method allows for POST uploading of files into the course asset library, which will
be supported by GridFS in MongoDB.
'''
#@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
be supported by GridFS in MongoDB.
'''
if request.method != 'POST':
# (cdodge) @todo: Is there a way to do a - say - 'raise Http400'?
return HttpResponseBadRequest()
@@ -454,7 +740,7 @@ def upload_asset(request, org, course, coursename):
if not has_access(request.user, location):
return HttpResponseForbidden()
# Does the course actually exist?!?
# Does the course actually exist?!? Get anything from it to prove its existance
try:
item = modulestore().get_item(location)
@@ -467,80 +753,61 @@ def upload_asset(request, org, course, coursename):
# 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
name = request.FILES['file'].name
filename = request.FILES['file'].name
mime_type = request.FILES['file'].content_type
filedata = request.FILES['file'].read()
file_location = StaticContent.compute_location_filename(org, course, name)
content_loc = StaticContent.compute_location(org, course, filename)
content = StaticContent(content_loc, filename, mime_type, filedata)
content = StaticContent(file_location, name, mime_type, filedata)
# first let's see if a thumbnail can be created
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content)
# first commit to the DB
# delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show)
del_cached_content(thumbnail_location)
# now store thumbnail location only if we could create it
if thumbnail_content is not None:
content.thumbnail_location = thumbnail_location
#then commit the content
contentstore().save(content)
del_cached_content(content.location)
# then remove the cache so we're not serving up stale content
# NOTE: we're not re-populating the cache here as the DB owns the last-modified timestamp
# which is used when serving up static content. This integrity is needed for
# browser-side caching support. We *could* re-fetch the saved content so that we have the
# timestamp populated, but we might as well wait for the first real request to come in
# to re-populate the cache.
del_cached_content(file_location)
# readback the saved content - we need the database timestamp
readback = contentstore().find(content.location)
response_payload = {'displayname' : content.name,
'uploadDate' : get_date_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'
}
# if we're uploading an image, then let's generate a thumbnail so that we can
# serve it up when needed without having to rescale on the fly
if mime_type.split('/')[0] == 'image':
try:
# not sure if this is necessary, but let's rewind the stream just in case
request.FILES['file'].seek(0)
# use PIL to do the thumbnail generation (http://www.pythonware.com/products/pil/)
# My understanding is that PIL will maintain aspect ratios while restricting
# the max-height/width to be whatever you pass in as 'size'
# @todo: move the thumbnail size to a configuration setting?!?
im = Image.open(request.FILES['file'])
# I've seen some exceptions from the PIL library when trying to save palletted
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
im = im.convert('RGB')
size = 128, 128
im.thumbnail(size, Image.ANTIALIAS)
thumbnail_file = StringIO.StringIO()
im.save(thumbnail_file, 'JPEG')
thumbnail_file.seek(0)
# use a naming convention to associate originals with the thumbnail
# <name_without_extention>.thumbnail.jpg
thumbnail_name = os.path.splitext(name)[0] + '.thumbnail.jpg'
# then just store this thumbnail as any other piece of content
thumbnail_file_location = StaticContent.compute_location_filename(org, course,
thumbnail_name)
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
'image/jpeg', thumbnail_file)
contentstore().save(thumbnail_content)
# remove any cached content at this location, as thumbnails are treated just like any
# other bit of static content
del_cached_content(thumbnail_file_location)
except:
# catch, log, and continue as thumbnails are not a hard requirement
logging.error('Failed to generate thumbnail for {0}. Continuing...'.format(name))
return HttpResponse('Upload completed')
response = HttpResponse(json.dumps(response_payload))
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
return response
'''
This view will return all CMS users who are editors for the specified course
'''
@login_required
@ensure_csrf_cookie
def manage_users(request, org, course, name):
location = ['i4x', org, course, 'course', name]
def manage_users(request, location):
# check that logged in user has permissions to this item
if not has_access(request.user, location, role=ADMIN_ROLE_NAME):
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME):
raise PermissionDenied()
course_module = modulestore().get_item(location)
return render_to_response('manage_users.html', {
'editors': get_users_in_course_group_by_role(location, EDITOR_ROLE_NAME)
'active_tab': 'users',
'context_course': course_module,
'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME),
'add_user_postback_url' : reverse('add_user', args=[location]).rstrip('/'),
'remove_user_postback_url' : reverse('remove_user', args=[location]).rstrip('/'),
'allow_actions' : has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME),
'request_user_id' : request.user.id
})
@@ -556,18 +823,17 @@ def create_json_response(errmsg = None):
This POST-back view will add a user - specified by email - to the list of editors for
the specified course
'''
@expect_json
@login_required
@ensure_csrf_cookie
def add_user(request, org, course, name):
def add_user(request, location):
email = request.POST["email"]
if email=='':
return create_json_response('Please specify an email address.')
location = ['i4x', org, course, 'course', name]
# check that logged in user has admin permissions to this course
if not has_access(request.user, location, role=ADMIN_ROLE_NAME):
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
raise PermissionDenied()
user = get_user_by_email(email)
@@ -581,7 +847,7 @@ def add_user(request, org, course, name):
return create_json_response('User {0} has registered but has not yet activated his/her account.'.format(email))
# ok, we're cool to add to the course group
add_user_to_course_group(request.user, user, location, EDITOR_ROLE_NAME)
add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME)
return create_json_response()
@@ -589,22 +855,521 @@ def add_user(request, org, course, name):
This POST-back view will remove a user - specified by email - from the list of editors for
the specified course
'''
@expect_json
@login_required
@ensure_csrf_cookie
def remove_user(request, org, course, name):
def remove_user(request, location):
email = request.POST["email"]
location = ['i4x', org, course, 'course', name]
# check that logged in user has admin permissions on this course
if not has_access(request.user, location, role=ADMIN_ROLE_NAME):
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
raise PermissionDenied()
user = get_user_by_email(email)
if user is None:
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
remove_user_from_course_group(request.user, user, location, EDITOR_ROLE_NAME)
# make sure we're not removing ourselves
if user.id == request.user.id:
raise PermissionDenied()
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
return create_json_response()
# points to the temporary course landing page with log in and sign up
def landing(request, org, course, coursename):
return render_to_response('temp-course-landing.html', {})
@login_required
@ensure_csrf_cookie
def static_pages(request, org, course, coursename):
location = ['i4x', org, course, 'course', coursename]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
course = modulestore().get_item(location)
return render_to_response('static-pages.html', {
'active_tab': 'pages',
'context_course': course,
})
def edit_static(request, org, course, coursename):
return render_to_response('edit-static-page.html', {})
@login_required
@ensure_csrf_cookie
def edit_tabs(request, org, course, coursename):
location = ['i4x', org, course, 'course', coursename]
course_item = modulestore().get_item(location)
static_tabs_loc = Location('i4x', org, course, 'static_tab', None)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
static_tabs = modulestore('direct').get_items(static_tabs_loc)
# see tabs have been uninitialized (e.g. supporing courses created before tab support in studio)
if course_item.tabs is None or len(course_item.tabs) == 0:
initialize_course_tabs(course_item)
components = [
static_tab.location.url()
for static_tab
in static_tabs
]
return render_to_response('edit-tabs.html', {
'active_tab': 'pages',
'context_course':course_item,
'components': components
})
def not_found(request):
return render_to_response('error.html', {'error': '404'})
def server_error(request):
return render_to_response('error.html', {'error': '500'})
@login_required
@ensure_csrf_cookie
def course_info(request, org, course, name, provided_id=None):
"""
Send models and views as well as html for editing the course info to the client.
org, course, name: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
course_module = modulestore().get_item(location)
# get current updates
location = ['i4x', org, course, 'course_info', "updates"]
return render_to_response('course_info.html', {
'active_tab': 'courseinfo-tab',
'context_course': course_module,
'url_base' : "/" + org + "/" + course + "/",
'course_updates' : json.dumps(get_course_updates(location)),
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
})
@expect_json
@login_required
@ensure_csrf_cookie
def course_info_updates(request, org, course, provided_id=None):
"""
restful CRUD operations on course_info updates.
org, course: Attributes of the Location for the item to edit
provided_id should be none if it's new (create) and a composite of the update db id + index otherwise.
"""
# ??? No way to check for access permission afaik
# get current updates
location = ['i4x', org, course, 'course_info', "updates"]
# Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
# Possibly due to my removing the seemingly redundant pattern in urls.py
if provided_id == '':
provided_id = None
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
if request.method == 'GET':
return HttpResponse(json.dumps(get_course_updates(location)), mimetype="application/json")
elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE
return HttpResponse(json.dumps(delete_course_update(location, request.POST, provided_id)), mimetype="application/json")
elif request.method == 'POST':
try:
return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
except:
return HttpResponseBadRequest("Failed to save: malformed html", content_type="text/plain")
@expect_json
@login_required
@ensure_csrf_cookie
def module_info(request, module_location):
location = Location(module_location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
rewrite_static_links = request.GET.get('rewrite_url_links','True') in ['True', 'true']
logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links','False'), rewrite_static_links))
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
if real_method == 'GET':
return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links)), mimetype="application/json")
elif real_method == 'POST' or real_method == 'PUT':
return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json")
else:
return HttpResponseBadRequest()
@login_required
@ensure_csrf_cookie
def get_course_settings(request, org, course, name):
"""
Send models and views as well as html for editing the course settings to the client.
org, course, name: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
course_module = modulestore().get_item(location)
course_details = CourseDetails.fetch(location)
return render_to_response('settings.html', {
'active_tab': 'settings',
'context_course': course_module,
'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder)
})
@expect_json
@login_required
@ensure_csrf_cookie
def course_settings_updates(request, org, course, name, section):
"""
restful CRUD operations on course settings. This differs from get_course_settings by communicating purely
through json (not rendering any html) and handles section level operations rather than whole page.
org, course: Attributes of the Location for the item to edit
section: one of details, faculty, grading, problems, discussions
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
if section == 'details':
manager = CourseDetails
elif section == 'grading':
manager = CourseGradingModel
else: return
if request.method == 'GET':
# Cannot just do a get w/o knowing the course name :-(
return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course',name])), cls=CourseSettingsEncoder),
mimetype="application/json")
elif request.method == 'POST': # post or put, doesn't matter.
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
mimetype="application/json")
@expect_json
@login_required
@ensure_csrf_cookie
def course_grader_updates(request, org, course, name, grader_index=None):
"""
restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely
through json (not rendering any html) and handles section level operations rather than whole page.
org, course: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
if real_method == 'GET':
# Cannot just do a get w/o knowing the course name :-(
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(['i4x', org, course, 'course',name]), grader_index)),
mimetype="application/json")
elif real_method == "DELETE":
# ??? Shoudl this return anything? Perhaps success fail?
CourseGradingModel.delete_grader(Location(['i4x', org, course, 'course',name]), grader_index)
return HttpResponse()
elif request.method == 'POST': # post or put, doesn't matter.
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course',name]), request.POST)),
mimetype="application/json")
@login_required
@ensure_csrf_cookie
def asset_index(request, org, course, name):
"""
Display an editable asset library
org, course, name: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
upload_asset_callback_url = reverse('upload_asset', kwargs = {
'org' : org,
'course' : course,
'coursename' : name
})
course_module = modulestore().get_item(location)
course_reference = StaticContent.compute_location(org, course, name)
assets = contentstore().get_all_content_for_course(course_reference)
# sort in reverse upload date order
assets = sorted(assets, key=lambda asset: asset['uploadDate'], reverse=True)
thumbnails = contentstore().get_all_content_thumbnails_for_course(course_reference)
asset_display = []
for asset in assets:
id = asset['_id']
display_info = {}
display_info['displayname'] = asset['displayname']
display_info['uploadDate'] = get_date_display(asset['uploadDate'])
asset_location = StaticContent.compute_location(id['org'], id['course'], id['name'])
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
_thumbnail_location = asset.get('thumbnail_location', None)
thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None
display_info['thumb_url'] = StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None
asset_display.append(display_info)
return render_to_response('asset_index.html', {
'active_tab': 'assets',
'context_course': course_module,
'assets': asset_display,
'upload_asset_callback_url': upload_asset_callback_url
})
# points to the temporary edge page
def edge(request):
return render_to_response('university_profiles/edge.html', {})
@login_required
@expect_json
def create_new_course(request):
template = Location(request.POST['template'])
org = request.POST.get('org')
number = request.POST.get('number')
display_name = request.POST.get('display_name')
try:
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
except InvalidLocationError as e:
return HttpResponse(json.dumps({'ErrMsg': "Unable to create course '" + display_name + "'.\n\n" + e.message}))
# see if the course already exists
existing_course = None
try:
existing_course = modulestore('direct').get_item(dest_location)
except ItemNotFoundError:
pass
if existing_course is not None:
return HttpResponse(json.dumps({'ErrMsg': 'There is already a course defined with this name.'}))
course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
courses = modulestore().get_items(course_search_location)
if len(courses) > 0:
return HttpResponse(json.dumps({'ErrMsg': 'There is already a course defined with the same organization and course number.'}))
new_course = modulestore('direct').clone_item(template, dest_location)
if display_name is not None:
new_course.metadata['display_name'] = display_name
# we need a 'data_dir' for legacy reasons
new_course.metadata['data_dir'] = uuid4().hex
# set a default start date to now
new_course.metadata['start'] = stringify_time(time.gmtime())
initialize_course_tabs(new_course)
create_all_course_groups(request.user, new_course.location)
return HttpResponse(json.dumps({'id': new_course.location.url()}))
def initialize_course_tabs(course):
# set up the default tabs
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
# at least a list populated with the minimal times
# @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
# place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
{"type": "progress", "name": "Progress"}]
modulestore('direct').update_metadata(course.location.url(), course.own_metadata)
@ensure_csrf_cookie
@login_required
def import_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
if request.method == 'POST':
filename = request.FILES['course-data'].name
if not filename.endswith('.tar.gz'):
return HttpResponse(json.dumps({'ErrMsg': 'We only support uploading a .tar.gz file.'}))
data_root = path(settings.GITHUB_REPO_ROOT)
course_subdir = "{0}-{1}-{2}".format(org, course, name)
course_dir = data_root / course_subdir
if not course_dir.isdir():
os.mkdir(course_dir)
temp_filepath = course_dir / filename
logging.debug('importing course to {0}'.format(temp_filepath))
# stream out the uploaded files in chunks to disk
temp_file = open(temp_filepath, 'wb+')
for chunk in request.FILES['course-data'].chunks():
temp_file.write(chunk)
temp_file.close()
tf = tarfile.open(temp_filepath)
tf.extractall(course_dir + '/')
# find the 'course.xml' file
for r,d,f in os.walk(course_dir):
for files in f:
if files == 'course.xml':
break
if files == 'course.xml':
break
if files != 'course.xml':
return HttpResponse(json.dumps({'ErrMsg': 'Could not find the course.xml file in the package.'}))
logging.debug('found course.xml at {0}'.format(r))
if r != course_dir:
for fname in os.listdir(r):
shutil.move(r/fname, course_dir)
module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT,
[course_subdir], load_error_modules=False, static_content_store=contentstore(), target_location_namespace = Location(location))
# we can blow this away when we're done importing.
shutil.rmtree(course_dir)
logging.debug('new course at {0}'.format(course_items[0].location))
create_all_course_groups(request.user, course_items[0].location)
return HttpResponse(json.dumps({'Status': 'OK'}))
else:
course_module = modulestore().get_item(location)
return render_to_response('import.html', {
'context_course': course_module,
'active_tab': 'import',
'successful_import_redirect_url' : reverse('course_index', args=[
course_module.location.org,
course_module.location.course,
course_module.location.name])
})
@ensure_csrf_cookie
@login_required
def generate_export_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
course_module = modulestore().get_item(location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
loc = Location(location)
export_file = NamedTemporaryFile(prefix=name+'.', suffix=".tar.gz")
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)
#filename = root_dir / name + '.tar.gz'
logging.debug('tar file being generated at {0}'.format(export_file.name))
tf = tarfile.open(name=export_file.name, mode='w:gz')
tf.add(root_dir/name, arcname=name)
tf.close()
# remove temp dir
shutil.rmtree(root_dir/name)
wrapper = FileWrapper(export_file)
response = HttpResponse(wrapper, content_type='application/x-tgz')
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name)
response['Content-Length'] = os.path.getsize(export_file.name)
return response
@ensure_csrf_cookie
@login_required
def export_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
course_module = modulestore().get_item(location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
return render_to_response('export.html', {
'context_course': course_module,
'active_tab': 'export',
'successful_import_redirect_url' : ''
})

View File

@@ -1,125 +0,0 @@
import logging
import os
from django.conf import settings
from fs.osfs import OSFS
from git import Repo, PushInfo
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.django import modulestore
from collections import namedtuple
from .exceptions import GithubSyncError, InvalidRepo
log = logging.getLogger(__name__)
RepoSettings = namedtuple('RepoSettings', 'path branch origin')
def sync_all_with_github():
"""
Sync all defined repositories from github
"""
for repo_name in settings.REPOS:
sync_with_github(load_repo_settings(repo_name))
def sync_with_github(repo_settings):
"""
Sync specified repository from github
repo_settings: A RepoSettings defining which repo to sync
"""
revision, course = import_from_github(repo_settings)
export_to_github(course, "Changes from cms import of revision %s" % revision, "CMS <cms@edx.org>")
def setup_repo(repo_settings):
"""
Reset the local github repo specified by repo_settings
repo_settings (RepoSettings): The settings for the repo to reset
"""
course_dir = repo_settings.path
repo_path = settings.GITHUB_REPO_ROOT / course_dir
if not os.path.isdir(repo_path):
Repo.clone_from(repo_settings.origin, repo_path)
git_repo = Repo(repo_path)
origin = git_repo.remotes.origin
origin.fetch()
# Do a hard reset to the remote branch so that we have a clean import
git_repo.git.checkout(repo_settings.branch)
return git_repo
def load_repo_settings(course_dir):
"""
Returns the repo_settings for the course stored in course_dir
"""
if course_dir not in settings.REPOS:
raise InvalidRepo(course_dir)
return RepoSettings(course_dir, **settings.REPOS[course_dir])
def import_from_github(repo_settings):
"""
Imports data into the modulestore based on the XML stored on github
"""
course_dir = repo_settings.path
git_repo = setup_repo(repo_settings)
git_repo.head.reset('origin/%s' % repo_settings.branch, index=True, working_tree=True)
module_store = import_from_xml(modulestore(),
settings.GITHUB_REPO_ROOT, course_dirs=[course_dir])
return git_repo.head.commit.hexsha, module_store.courses[course_dir]
def export_to_github(course, commit_message, author_str=None):
'''
Commit any changes to the specified course with given commit message,
and push to github (if MITX_FEATURES['GITHUB_PUSH'] is True).
If author_str is specified, uses it in the commit.
'''
course_dir = course.metadata.get('data_dir', course.location.course)
try:
repo_settings = load_repo_settings(course_dir)
except InvalidRepo:
log.warning("Invalid repo {0}, not exporting data to xml".format(course_dir))
return
git_repo = setup_repo(repo_settings)
fs = OSFS(git_repo.working_dir)
xml = course.export_to_xml(fs)
with fs.open('course.xml', 'w') as course_xml:
course_xml.write(xml)
if git_repo.is_dirty():
git_repo.git.add(A=True)
if author_str is not None:
git_repo.git.commit(m=commit_message, author=author_str)
else:
git_repo.git.commit(m=commit_message)
origin = git_repo.remotes.origin
if settings.MITX_FEATURES['GITHUB_PUSH']:
push_infos = origin.push()
if len(push_infos) > 1:
log.error('Unexpectedly pushed multiple heads: {infos}'.format(
infos="\n".join(str(info.summary) for info in push_infos)
))
if push_infos[0].flags & PushInfo.ERROR:
log.error('Failed push: flags={p.flags}, local_ref={p.local_ref}, '
'remote_ref_string={p.remote_ref_string}, '
'remote_ref={p.remote_ref}, old_commit={p.old_commit}, '
'summary={p.summary})'.format(p=push_infos[0]))
raise GithubSyncError('Failed to push: {info}'.format(
info=str(push_infos[0].summary)
))

View File

@@ -1,6 +0,0 @@
class GithubSyncError(Exception):
pass
class InvalidRepo(Exception):
pass

View File

@@ -1,14 +0,0 @@
###
### Script for syncing CMS with defined github repos
###
from django.core.management.base import NoArgsCommand
from github_sync import sync_all_with_github
class Command(NoArgsCommand):
help = \
'''Sync the CMS with the defined github repos'''
def handle_noargs(self, **options):
sync_all_with_github()

View File

@@ -1,108 +0,0 @@
from django.test import TestCase
from path import path
import shutil
from github_sync import (
import_from_github, export_to_github, load_repo_settings,
sync_all_with_github, sync_with_github
)
from git import Repo
from django.conf import settings
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from override_settings import override_settings
from github_sync.exceptions import GithubSyncError
from mock import patch, Mock
REPO_DIR = settings.GITHUB_REPO_ROOT / 'local_repo'
WORKING_DIR = path(settings.TEST_ROOT)
REMOTE_DIR = WORKING_DIR / 'remote_repo'
@override_settings(REPOS={
'local_repo': {
'origin': REMOTE_DIR,
'branch': 'master',
}
})
class GithubSyncTestCase(TestCase):
def cleanup(self):
shutil.rmtree(REPO_DIR, ignore_errors=True)
shutil.rmtree(REMOTE_DIR, ignore_errors=True)
modulestore().collection.drop()
def setUp(self):
# make sure there's no stale data lying around
self.cleanup()
shutil.copytree('common/test/data/toy', REMOTE_DIR)
remote = Repo.init(REMOTE_DIR)
remote.git.add(A=True)
remote.git.commit(m='Initial commit')
remote.git.config("receive.denyCurrentBranch", "ignore")
self.import_revision, self.import_course = import_from_github(load_repo_settings('local_repo'))
def tearDown(self):
self.cleanup()
def test_initialize_repo(self):
"""
Test that importing from github will create a repo if the repo doesn't already exist
"""
self.assertEquals(1, len(Repo(REPO_DIR).head.reference.log()))
def test_import_contents(self):
"""
Test that the import loads the correct course into the modulestore
"""
self.assertEquals('Toy Course', self.import_course.metadata['display_name'])
self.assertIn(
Location('i4x://edX/toy/chapter/Overview'),
[child.location for child in self.import_course.get_children()])
self.assertEquals(2, len(self.import_course.get_children()))
@patch('github_sync.sync_with_github')
def test_sync_all_with_github(self, sync_with_github):
sync_all_with_github()
sync_with_github.assert_called_with(load_repo_settings('local_repo'))
def test_sync_with_github(self):
with patch('github_sync.import_from_github', Mock(return_value=(Mock(), Mock()))) as import_from_github:
with patch('github_sync.export_to_github') as export_to_github:
settings = load_repo_settings('local_repo')
sync_with_github(settings)
import_from_github.assert_called_with(settings)
export_to_github.assert_called
@override_settings(MITX_FEATURES={'GITHUB_PUSH': False})
def test_export_no_pash(self):
"""
Test that with the GITHUB_PUSH feature disabled, no content is pushed to the remote
"""
export_to_github(self.import_course, 'Test no-push')
self.assertEquals(1, Repo(REMOTE_DIR).head.commit.count())
@override_settings(MITX_FEATURES={'GITHUB_PUSH': True})
def test_export_push(self):
"""
Test that with GITHUB_PUSH enabled, content is pushed to the remote
"""
self.import_course.metadata['display_name'] = 'Changed display name'
export_to_github(self.import_course, 'Test push')
self.assertEquals(2, Repo(REMOTE_DIR).head.commit.count())
@override_settings(MITX_FEATURES={'GITHUB_PUSH': True})
def test_export_conflict(self):
"""
Test that if there is a conflict when pushing to the remote repo, nothing is pushed and an exception is raised
"""
self.import_course.metadata['display_name'] = 'Changed display name'
remote = Repo(REMOTE_DIR)
remote.git.commit(allow_empty=True, m="Testing conflict commit")
self.assertRaises(GithubSyncError, export_to_github, self.import_course, 'Test push')
self.assertEquals(2, remote.head.reference.commit.count())
self.assertEquals("Testing conflict commit\n", remote.head.reference.commit.message)

View File

@@ -1,43 +0,0 @@
import json
from django.test.client import Client
from django.test import TestCase
from mock import patch
from override_settings import override_settings
from github_sync import load_repo_settings
@override_settings(REPOS={'repo': {'branch': 'branch', 'origin': 'origin'}})
class PostReceiveTestCase(TestCase):
def setUp(self):
self.client = Client()
@patch('github_sync.views.import_from_github')
def test_non_branch(self, import_from_github):
self.client.post('/github_service_hook', {'payload': json.dumps({
'ref': 'refs/tags/foo'})
})
self.assertFalse(import_from_github.called)
@patch('github_sync.views.import_from_github')
def test_non_watched_repo(self, import_from_github):
self.client.post('/github_service_hook', {'payload': json.dumps({
'ref': 'refs/heads/branch',
'repository': {'name': 'bad_repo'}})
})
self.assertFalse(import_from_github.called)
@patch('github_sync.views.import_from_github')
def test_non_tracked_branch(self, import_from_github):
self.client.post('/github_service_hook', {'payload': json.dumps({
'ref': 'refs/heads/non_branch',
'repository': {'name': 'repo'}})
})
self.assertFalse(import_from_github.called)
@patch('github_sync.views.import_from_github')
def test_tracked_branch(self, import_from_github):
self.client.post('/github_service_hook', {'payload': json.dumps({
'ref': 'refs/heads/branch',
'repository': {'name': 'repo'}})
})
import_from_github.assert_called_with(load_repo_settings('repo'))

View File

@@ -1,51 +0,0 @@
import logging
import json
from django.http import HttpResponse
from django.conf import settings
from django_future.csrf import csrf_exempt
from . import import_from_github, load_repo_settings
log = logging.getLogger()
@csrf_exempt
def github_post_receive(request):
"""
This view recieves post-receive requests from github whenever one of
the watched repositiories changes.
It is responsible for updating the relevant local git repo,
importing the new version of the course (if anything changed),
and then pushing back to github any changes that happened as part of the
import.
The github request format is described here: https://help.github.com/articles/post-receive-hooks
"""
payload = json.loads(request.POST['payload'])
ref = payload['ref']
if not ref.startswith('refs/heads/'):
log.info('Ignore changes to non-branch ref %s' % ref)
return HttpResponse('Ignoring non-branch')
branch_name = ref.replace('refs/heads/', '', 1)
repo_name = payload['repository']['name']
if repo_name not in settings.REPOS:
log.info('No repository matching %s found' % repo_name)
return HttpResponse('No Repo Found')
repo = load_repo_settings(repo_name)
if repo.branch != branch_name:
log.info('Ignoring changes to non-tracked branch %s in repo %s' % (branch_name, repo_name))
return HttpResponse('Ignoring non-tracked branch')
import_from_github(repo)
return HttpResponse('Push received')

View File

@@ -0,0 +1,183 @@
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
import json
from json.encoder import JSONEncoder
import time
from contentstore.utils import get_modulestore
from util.converters import jsdate_to_time, time_to_date
from cms.djangoapps.models.settings import course_grading
from cms.djangoapps.contentstore.utils import update_item
import re
import logging
class CourseDetails(object):
def __init__(self, location):
self.course_location = location # a Location obj
self.start_date = None # 'start'
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
@classmethod
def fetch(cls, course_location):
"""
Fetch the course details for the given course from persistence and return a CourseDetails model.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
course = cls(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
course.start_date = descriptor.start
course.end_date = descriptor.end
course.enrollment_start = descriptor.enrollment_start
course.enrollment_end = descriptor.enrollment_end
temploc = course_location._replace(category='about', name='syllabus')
try:
course.syllabus = get_modulestore(temploc).get_item(temploc).definition['data']
except ItemNotFoundError:
pass
temploc = temploc._replace(name='overview')
try:
course.overview = get_modulestore(temploc).get_item(temploc).definition['data']
except ItemNotFoundError:
pass
temploc = temploc._replace(name='effort')
try:
course.effort = get_modulestore(temploc).get_item(temploc).definition['data']
except ItemNotFoundError:
pass
temploc = temploc._replace(name='video')
try:
raw_video = get_modulestore(temploc).get_item(temploc).definition['data']
course.intro_video = CourseDetails.parse_video_tag(raw_video)
except ItemNotFoundError:
pass
return course
@classmethod
def update_from_json(cls, jsondict):
"""
Decode the json into CourseDetails and save any changed attrs to the db
"""
## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
course_location = jsondict['course_location']
## Will probably want to cache the inflight courses because every blur generates an update
descriptor = get_modulestore(course_location).get_item(course_location)
dirty = False
if 'start_date' in jsondict:
converted = jsdate_to_time(jsondict['start_date'])
else:
converted = None
if converted != descriptor.start:
dirty = True
descriptor.start = converted
if 'end_date' in jsondict:
converted = jsdate_to_time(jsondict['end_date'])
else:
converted = None
if converted != descriptor.end:
dirty = True
descriptor.end = converted
if 'enrollment_start' in jsondict:
converted = jsdate_to_time(jsondict['enrollment_start'])
else:
converted = None
if converted != descriptor.enrollment_start:
dirty = True
descriptor.enrollment_start = converted
if 'enrollment_end' in jsondict:
converted = jsdate_to_time(jsondict['enrollment_end'])
else:
converted = None
if converted != descriptor.enrollment_end:
dirty = True
descriptor.enrollment_end = converted
if dirty:
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
# 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')
update_item(temploc, jsondict['syllabus'])
temploc = temploc._replace(name='overview')
update_item(temploc, jsondict['overview'])
temploc = temploc._replace(name='effort')
update_item(temploc, jsondict['effort'])
temploc = temploc._replace(name='video')
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
update_item(temploc, recomposed_video_tag)
# Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm
# it persisted correctly
return CourseDetails.fetch(course_location)
@staticmethod
def parse_video_tag(raw_video):
"""
Because the client really only wants the author to specify the youtube key, that's all we send to and get from the client.
The problem is that the db stores the html markup as well (which, of course, makes any sitewide changes to how we do videos
next to impossible.)
"""
if not raw_video:
return None
keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher is None:
keystring_matcher = re.search('<?=\d+:[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher:
return keystring_matcher.group(0)
else:
logging.warn("ignoring the content because it doesn't not conform to expected pattern: " + raw_video)
return None
@staticmethod
def recompose_video_tag(video_key):
# TODO should this use a mako template? Of course, my hope is that this is a short-term workaround for the db not storing
# the right thing
result = None
if video_key:
result = '<iframe width="560" height="315" src="http://www.youtube.com/embed/' + \
video_key + '?autoplay=1&rel=0" frameborder="0" allowfullscreen=""></iframe>'
return result
# TODO move to a more general util? Is there a better way to do the isinstance model check?
class CourseSettingsEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel):
return obj.__dict__
elif isinstance(obj, Location):
return obj.dict()
elif isinstance(obj, time.struct_time):
return time_to_date(obj)
else:
return JSONEncoder.default(self, obj)

View File

@@ -0,0 +1,265 @@
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
import re
from util import converters
class CourseGradingModel(object):
"""
Basically a DAO and Model combo for CRUD operations pertaining to grading policy.
"""
def __init__(self, course_descriptor):
self.course_location = course_descriptor.location
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
self.grade_cutoffs = course_descriptor.grade_cutoffs
self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor)
@classmethod
def fetch(cls, course_location):
"""
Fetch the course details for the given course from persistence and return a CourseDetails model.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
model = cls(descriptor)
return model
@staticmethod
def fetch_grader(course_location, index):
"""
Fetch the course's nth grader
Returns an empty dict if there's no such grader.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
# # ??? it would be good if these had the course_location in them so that they stand alone sufficiently
# # but that would require not using CourseDescriptor's field directly. Opinions?
index = int(index)
if len(descriptor.raw_grader) > index:
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
# return empty model
else:
return {
"id" : index,
"type" : "",
"min_count" : 0,
"drop_count" : 0,
"short_label" : None,
"weight" : 0
}
@staticmethod
def fetch_cutoffs(course_location):
"""
Fetch the course's grade cutoffs.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
return descriptor.grade_cutoffs
@staticmethod
def fetch_grace_period(course_location):
"""
Fetch the course's default grace period.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
return {'grace_period' : CourseGradingModel.convert_set_grace_period(descriptor) }
@staticmethod
def update_from_json(jsondict):
"""
Decode the json into CourseGradingModel and save any changes. Returns the modified model.
Probably not the usual path for updates as it's too coarse grained.
"""
course_location = jsondict['course_location']
descriptor = get_modulestore(course_location).get_item(course_location)
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
descriptor.raw_grader = graders_parsed
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
return CourseGradingModel.fetch(course_location)
@staticmethod
def update_grader_from_json(course_location, grader):
"""
Create or update the grader of the given type (string key) for the given course. Returns the modified
grader which is a full model on the client but not on the server (just a dict)
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
# # ??? it would be good if these had the course_location in them so that they stand alone sufficiently
# # but that would require not using CourseDescriptor's field directly. Opinions?
# parse removes the id; so, grab it before parse
index = int(grader.get('id', len(descriptor.raw_grader)))
grader = CourseGradingModel.parse_grader(grader)
if index < len(descriptor.raw_grader):
descriptor.raw_grader[index] = grader
else:
descriptor.raw_grader.append(grader)
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
@staticmethod
def update_cutoffs_from_json(course_location, cutoffs):
"""
Create or update the grade cutoffs for the given course. Returns sent in cutoffs (ie., no extra
db fetch).
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.grade_cutoffs = cutoffs
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
return cutoffs
@staticmethod
def update_grace_period_from_json(course_location, graceperiodjson):
"""
Update the course's default grace period. Incoming dict is {hours: h, minutes: m} possibly as a
grace_period entry in an enclosing dict. It is also safe to call this method with a value of
None for graceperiodjson.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
# Before a graceperiod has ever been created, it will be None (once it has been
# created, it cannot be set back to None).
if graceperiodjson is not None:
if 'grace_period' in graceperiodjson:
graceperiodjson = graceperiodjson['grace_period']
grace_rep = " ".join(["%s %s" % (value, key) for (key, value) in graceperiodjson.iteritems()])
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.metadata['graceperiod'] = grace_rep
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
@staticmethod
def delete_grader(course_location, index):
"""
Delete the grader of the given type from the given course.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
index = int(index)
if index < len(descriptor.raw_grader):
del descriptor.raw_grader[index]
# force propagation to definition
descriptor.raw_grader = descriptor.raw_grader
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
# NOTE cannot delete cutoffs. May be useful to reset
@staticmethod
def delete_cutoffs(course_location, cutoffs):
"""
Resets the cutoffs to the defaults
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS']
get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
return descriptor.grade_cutoffs
@staticmethod
def delete_grace_period(course_location):
"""
Delete the course's default grace period.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
if 'graceperiod' in descriptor.metadata: del descriptor.metadata['graceperiod']
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
@staticmethod
def get_section_grader_type(location):
if not isinstance(location, Location):
location = Location(location)
descriptor = get_modulestore(location).get_item(location)
return {
"graderType" : descriptor.metadata.get('format', u"Not Graded"),
"location" : location,
"id" : 99 # just an arbitrary value to
}
@staticmethod
def update_section_grader_type(location, jsondict):
if not isinstance(location, Location):
location = Location(location)
descriptor = get_modulestore(location).get_item(location)
if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded":
descriptor.metadata['format'] = jsondict.get('graderType')
descriptor.metadata['graded'] = True
else:
if 'format' in descriptor.metadata: del descriptor.metadata['format']
if 'graded' in descriptor.metadata: del descriptor.metadata['graded']
get_modulestore(location).update_metadata(location, descriptor.metadata)
@staticmethod
def convert_set_grace_period(descriptor):
# 5 hours 59 minutes 59 seconds => converted to iso format
rawgrace = descriptor.metadata.get('graceperiod', None)
if rawgrace:
parsedgrace = {str(key): val for (val, key) in re.findall('\s*(\d+)\s*(\w+)', rawgrace)}
return parsedgrace
else: return None
@staticmethod
def parse_grader(json_grader):
# manual to clear out kruft
result = {
"type" : json_grader["type"],
"min_count" : int(json_grader.get('min_count', 0)),
"drop_count" : int(json_grader.get('drop_count', 0)),
"short_label" : json_grader.get('short_label', None),
"weight" : float(json_grader.get('weight', 0)) / 100.0
}
return result
@staticmethod
def jsonize_grader(i, grader):
grader['id'] = i
if grader['weight']:
grader['weight'] *= 100
if not 'short_label' in grader:
grader['short_label'] = ""
return grader

38
cms/envs/acceptance.py Normal file
View File

@@ -0,0 +1,38 @@
"""
This config file extends the test environment configuration
so that we can run the lettuce acceptance tests.
"""
from .test import *
# You need to start the server in debug mode,
# otherwise the browser will not render the pages correctly
DEBUG = True
# Show the courses that are in the data directory
COURSES_ROOT = ENV_ROOT / "data"
DATA_DIR = COURSES_ROOT
# MODULESTORE = {
# 'default': {
# 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
# 'OPTIONS': {
# 'data_dir': DATA_DIR,
# 'default_class': 'xmodule.hidden_module.HiddenDescriptor',
# }
# }
# }
# Set this up so that rake lms[acceptance] and running the
# harvest command both use the same (test) database
# which they can flush without messing up your dev db
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ENV_ROOT / "db" / "test_mitx.db",
'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db",
}
}
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('contentstore',)
LETTUCE_SERVER_PORT = 8001

View File

@@ -3,8 +3,8 @@ This is the default template for our main set of AWS servers.
"""
import json
from .logsettings import get_logger_config
from .common import *
from logsettings import get_logger_config
############################### ALWAYS THE SAME ################################
DEBUG = False
@@ -27,6 +27,8 @@ LOG_DIR = ENV_TOKENS['LOG_DIR']
CACHES = ENV_TOKENS['CACHES']
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
MITX_FEATURES[feature] = value
@@ -48,3 +50,4 @@ AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
DATABASES = AUTH_TOKENS['DATABASES']
MODULESTORE = AUTH_TOKENS['MODULESTORE']
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']

View File

@@ -23,20 +23,20 @@ import sys
import tempfile
import os.path
import os
import errno
import glob2
import lms.envs.common
import hashlib
from collections import defaultdict
from path import path
from xmodule.static_content import write_descriptor_styles, write_descriptor_js, write_module_js, write_module_styles
############################ FEATURE CONFIGURATION #############################
MITX_FEATURES = {
'USE_DJANGO_PIPELINE': True,
'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False
'ENABLE_DISCUSSION_SERVICE': False,
'AUTH_USE_MIT_CERTIFICATES' : False,
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
}
ENABLE_JASMINE = False
# needed to use lms student app
GENERATE_RANDOM_USER_CREDENTIALS = False
@@ -70,9 +70,7 @@ MAKO_TEMPLATES['main'] = [
for namespace, template_dirs in lms.envs.common.MAKO_TEMPLATES.iteritems():
MAKO_TEMPLATES['lms.' + namespace] = template_dirs
TEMPLATE_DIRS = (
PROJECT_ROOT / "templates",
)
TEMPLATE_DIRS = MAKO_TEMPLATES['main']
MITX_ROOT_URL = ''
@@ -90,10 +88,6 @@ TEMPLATE_CONTEXT_PROCESSORS = (
LMS_BASE = None
################################# Jasmine ###################################
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
#################### CAPA External Code Evaluation #############################
XQUEUE_INTERFACE = {
'url': 'http://localhost:8888',
@@ -194,71 +188,36 @@ STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
# Load javascript and css from all of the available descriptors, and
# prep it for use in pipeline js
from xmodule.x_module import XModuleDescriptor
from xmodule.raw_module import RawDescriptor
from xmodule.error_module import ErrorDescriptor
js_file_dir = PROJECT_ROOT / "static" / "coffee" / "module"
css_file_dir = PROJECT_ROOT / "static" / "sass" / "module"
module_styles_path = css_file_dir / "_module-styles.scss"
from rooted_paths import rooted_glob, remove_root
for dir_ in (js_file_dir, css_file_dir):
try:
os.makedirs(dir_)
except OSError as exc:
if exc.errno == errno.EEXIST:
pass
else:
raise
write_descriptor_styles(PROJECT_ROOT / "static/sass/descriptor", [RawDescriptor, ErrorDescriptor])
write_module_styles(PROJECT_ROOT / "static/sass/module", [RawDescriptor, ErrorDescriptor])
js_fragments = set()
css_fragments = defaultdict(set)
for _, descriptor in XModuleDescriptor.load_classes() + [(None, RawDescriptor), (None, ErrorDescriptor)]:
descriptor_js = descriptor.get_javascript()
module_js = descriptor.module_class.get_javascript()
for filetype in ('coffee', 'js'):
for idx, fragment in enumerate(descriptor_js.get(filetype, []) + module_js.get(filetype, [])):
js_fragments.add((idx, filetype, fragment))
for class_ in (descriptor, descriptor.module_class):
fragments = class_.get_css()
for filetype in ('sass', 'scss', 'css'):
for idx, fragment in enumerate(fragments.get(filetype, [])):
css_fragments[idx, filetype, fragment].add(class_.__name__)
module_js_sources = []
for idx, filetype, fragment in sorted(js_fragments):
path = js_file_dir / "{idx}-{hash}.{type}".format(
idx=idx,
hash=hashlib.md5(fragment).hexdigest(),
type=filetype)
with open(path, 'w') as js_file:
js_file.write(fragment)
module_js_sources.append(path.replace(PROJECT_ROOT / "static/", ""))
css_imports = defaultdict(set)
for (idx, filetype, fragment), classes in sorted(css_fragments.items()):
fragment_name = "{idx}-{hash}.{type}".format(
idx=idx,
hash=hashlib.md5(fragment).hexdigest(),
type=filetype)
# Prepend _ so that sass just includes the files into a single file
with open(css_file_dir / '_' + fragment_name, 'w') as js_file:
js_file.write(fragment)
for class_ in classes:
css_imports[class_].add(fragment_name)
with open(module_styles_path, 'w') as module_styles:
for class_, fragment_names in css_imports.items():
imports = "\n".join('@import "{0}";'.format(name) for name in fragment_names)
module_styles.write(""".xmodule_{class_} {{ {imports} }}""".format(
class_=class_, imports=imports
))
descriptor_js = remove_root(
PROJECT_ROOT / 'static',
write_descriptor_js(
PROJECT_ROOT / "static/coffee/descriptor",
[RawDescriptor, ErrorDescriptor]
)
)
module_js = remove_root(
PROJECT_ROOT / 'static',
write_module_js(
PROJECT_ROOT / "static/coffee/module",
[RawDescriptor, ErrorDescriptor]
)
)
PIPELINE_CSS = {
'base-style': {
'source_filenames': ['sass/base-style.scss'],
'source_filenames': [
'js/vendor/CodeMirror/codemirror.css',
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
'css/vendor/jquery.qtip.min.css',
'sass/base-style.scss'
],
'output_filename': 'css/cms-base-style.css',
},
}
@@ -267,23 +226,18 @@ PIPELINE_ALWAYS_RECOMPILE = ['sass/base-style.scss']
PIPELINE_JS = {
'main': {
'source_filenames': [
pth.replace(COMMON_ROOT / 'static/', '')
for pth
in glob2.glob(COMMON_ROOT / 'static/coffee/src/**/*.coffee')
] + [
pth.replace(PROJECT_ROOT / 'static/', '')
for pth
in glob2.glob(PROJECT_ROOT / 'static/coffee/src/**/*.coffee')
],
'source_filenames': sorted(
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.coffee') +
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.coffee')
) + ['js/base.js'],
'output_filename': 'js/cms-application.js',
},
'module-js': {
'source_filenames': module_js_sources,
'source_filenames': descriptor_js + module_js,
'output_filename': 'js/cms-modules.js',
},
'spec': {
'source_filenames': [pth.replace(PROJECT_ROOT / 'static/', '') for pth in glob2.glob(PROJECT_ROOT / 'static/coffee/spec/**/*.coffee')],
'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.coffee')),
'output_filename': 'js/cms-spec.js'
}
}
@@ -326,13 +280,9 @@ INSTALLED_APPS = (
# For CMS
'contentstore',
'auth',
'github_sync',
'student', # misleading name due to sharing with lms
# For asset pipelining
'pipeline',
'staticfiles',
# For testing
'django_jasmine',
)

View File

@@ -2,7 +2,7 @@
This config file runs the simplest dev environment"""
from .common import *
from .logsettings import get_logger_config
from logsettings import get_logger_config
import logging
import sys
@@ -12,19 +12,26 @@ TEMPLATE_DEBUG = DEBUG
LOGGING = get_logger_config(ENV_ROOT / "log",
logging_env="dev",
tracking_filename="tracking.log",
dev_env = True,
debug=True)
modulestore_options = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'xmodule',
'collection': 'modulestore',
'fs_root': GITHUB_REPO_ROOT,
'render_template': 'mitxmako.shortcuts.render_to_string',
}
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'OPTIONS': modulestore_options
},
'direct': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'xmodule',
'collection': 'modulestore',
'fs_root': GITHUB_REPO_ROOT,
'render_template': 'mitxmako.shortcuts.render_to_string',
}
'OPTIONS': modulestore_options
}
}
@@ -42,11 +49,11 @@ CONTENTSTORE = {
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ENV_ROOT / "db" / "cms.db",
'NAME': ENV_ROOT / "db" / "mitx.db",
}
}
LMS_BASE = "http://localhost:8000"
LMS_BASE = "localhost:8000"
REPOS = {
'edx4edx': {
@@ -97,3 +104,6 @@ CACHES = {
# Make the keyedcache startup warnings go away
CACHE_TIMEOUT = 0
# Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'

16
cms/envs/dev_ike.py Normal file
View File

@@ -0,0 +1,16 @@
# dev environment for ichuang/mit
# 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
MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 for nginx ssl proxy

38
cms/envs/jasmine.py Normal file
View File

@@ -0,0 +1,38 @@
"""
This configuration is used for running jasmine tests
"""
from .test import *
from logsettings import get_logger_config
ENABLE_JASMINE = True
DEBUG = True
LOGGING = get_logger_config(TEST_ROOT / "log",
logging_env="dev",
tracking_filename="tracking.log",
dev_env=True,
debug=True,
local_loglevel='ERROR',
console_loglevel='ERROR')
PIPELINE_JS['js-test-source'] = {
'source_filenames': sum([
pipeline_group['source_filenames']
for group_name, pipeline_group
in PIPELINE_JS.items()
if group_name != 'spec'
], []),
'output_filename': 'js/cms-test-source.js'
}
PIPELINE_JS['spec'] = {
'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.coffee')),
'output_filename': 'js/cms-spec.js'
}
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
STATICFILES_DIRS.append(COMMON_ROOT / 'test' / 'phantom-jasmine' / 'lib')
INSTALLED_APPS += ('django_jasmine', )

View File

@@ -1,96 +0,0 @@
import os
import os.path
import platform
import sys
def get_logger_config(log_dir,
logging_env="no_env",
tracking_filename=None,
syslog_addr=None,
debug=False):
"""Return the appropriate logging config dictionary. You should assign the
result of this to the LOGGING var in your settings. The reason it's done
this way instead of registering directly is because I didn't want to worry
about resetting the logging state if this is called multiple times when
settings are extended."""
# If we're given an explicit place to put tracking logs, we do that (say for
# debugging). However, logging is not safe for multiple processes hitting
# the same file. So if it's left blank, we dynamically create the filename
# based on the PID of this worker process.
if tracking_filename:
tracking_file_loc = os.path.join(log_dir, tracking_filename)
else:
pid = os.getpid() # So we can log which process is creating the log
tracking_file_loc = os.path.join(log_dir, "tracking_{0}.log".format(pid))
hostname = platform.node().split(".")[0]
syslog_format = ("[%(name)s][env:{logging_env}] %(levelname)s [{hostname} " +
" %(process)d] [%(filename)s:%(lineno)d] - %(message)s").format(
logging_env=logging_env, hostname=hostname)
handlers = ['console'] if debug else ['console', 'syslogger', 'newrelic']
return {
'version': 1,
'disable_existing_loggers': False,
'formatters' : {
'standard' : {
'format' : '%(asctime)s %(levelname)s %(process)d [%(name)s] %(filename)s:%(lineno)d - %(message)s',
},
'syslog_format' : { 'format' : syslog_format },
'raw' : { 'format' : '%(message)s' },
},
'handlers' : {
'console' : {
'level' : 'DEBUG' if debug else 'INFO',
'class' : 'logging.StreamHandler',
'formatter' : 'standard',
'stream' : sys.stdout,
},
'syslogger' : {
'level' : 'INFO',
'class' : 'logging.handlers.SysLogHandler',
'address' : syslog_addr,
'formatter' : 'syslog_format',
},
'tracking' : {
'level' : 'DEBUG',
'class' : 'logging.handlers.WatchedFileHandler',
'filename' : tracking_file_loc,
'formatter' : 'raw',
},
'newrelic' : {
'level': 'ERROR',
'class': 'newrelic_logging.NewRelicHandler',
'formatter': 'raw',
}
},
'loggers' : {
'django' : {
'handlers' : handlers,
'propagate' : True,
'level' : 'INFO'
},
'tracking' : {
'handlers' : ['tracking'],
'level' : 'DEBUG',
'propagate' : False,
},
'' : {
'handlers' : handlers,
'level' : 'DEBUG',
'propagate' : False
},
'mitx' : {
'handlers' : handlers,
'level' : 'DEBUG',
'propagate' : False
},
'keyedcache' : {
'handlers' : handlers,
'level' : 'DEBUG',
'propagate' : False
},
}
}

View File

@@ -19,6 +19,9 @@ TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
TEST_ROOT = path('test_root')
# Makes the tests run much faster...
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
# Want static files in the same dir for running on jenkins.
STATIC_ROOT = TEST_ROOT / "staticfiles"
@@ -36,17 +39,31 @@ STATICFILES_DIRS += [
if os.path.isdir(COMMON_TEST_DATA_ROOT / course_dir)
]
modulestore_options = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'test_xmodule',
'collection': 'modulestore',
'fs_root': GITHUB_REPO_ROOT,
'render_template': 'mitxmako.shortcuts.render_to_string',
}
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'test_xmodule',
'collection': 'modulestore',
'fs_root': GITHUB_REPO_ROOT,
'render_template': 'mitxmako.shortcuts.render_to_string',
}
'OPTIONS': modulestore_options
},
'direct': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': modulestore_options
}
}
CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db' : 'xcontent',
}
}
@@ -68,6 +85,8 @@ DATABASES = {
}
}
LMS_BASE = "localhost:8000"
CACHES = {
# This is the cache used for most things. Askbot will not work without a
# functioning cache -- it relies on caching to load its settings in places.
@@ -90,3 +109,10 @@ CACHES = {
'KEY_FUNCTION': 'util.memcache.safe_key',
}
}
################### Make tests faster
#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',
)

View File

@@ -0,0 +1,69 @@
<li class="input input-existing multi course-grading-assignment-list-item">
<div class="row row-col2">
<label for="course-grading-assignment-name">Assignment Type Name:</label>
<div class="field">
<div class="input course-grading-assignment-name">
<input type="text" class="long"
id="course-grading-assignment-name" value="<%= model.get('type') %>">
<span class="tip tip-stacked">e.g. Homework, Labs, Midterm Exams, Final Exam</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-shortname">Abbreviation:</label>
<div class="field">
<div class="input course-grading-shortname">
<input type="text" class="short"
id="course-grading-assignment-shortname"
value="<%= model.get('short_label') %>">
<span class="tip tip-inline">e.g. HW, Midterm, Final</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-gradeweight">Weight of Total
Grade:</label>
<div class="field">
<div class="input course-grading-gradeweight">
<input type="text" class="short"
id="course-grading-assignment-gradeweight"
value = "<%= model.get('weight') %>">
<span class="tip tip-inline">e.g. 25%</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-totalassignments">Total
Number:</label>
<div class="field">
<div class="input course-grading-totalassignments">
<input type="text" class="short"
id="course-grading-assignment-totalassignments"
value = "<%= model.get('min_count') %>">
<span class="tip tip-inline">total exercises assigned</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-droppable">Number of
Droppable:</label>
<div class="field">
<div class="input course-grading-droppable">
<input type="text" class="short"
id="course-grading-assignment-droppable"
value = "<%= model.get('drop_count') %>">
<span class="tip tip-inline">total exercises that won't be graded</span>
</div>
</div>
</div>
<a href="#" class="delete-button standard remove-item remove-grading-data"><span class="delete-icon"></span>Delete</a>
</li>

View File

@@ -0,0 +1,19 @@
<a href="#" class="edit-button"><span class="edit-icon"></span>Edit</a>
<h2>Course Handouts</h2>
<%if (model.get('data') != null) { %>
<div class="handouts-content">
<%= model.get('data') %>
</div>
<% } else {%>
<p>You have no handouts defined</p>
<% } %>
<form class="edit-handouts-form" style="display: block;">
<div class="row">
<textarea class="handouts-content-editor text-editor"></textarea>
</div>
<div class="row">
<a href="#" class="save-button">Save</a>
<a href="#" class="cancel-button">Cancel</a>
</div>
</form>

View File

@@ -0,0 +1,29 @@
<li name="<%- updateModel.cid %>">
<!-- FIXME what style should we use for initially hidden? --> <!-- TODO decide whether this should use codemirror -->
<form class="new-update-form">
<div class="row">
<label class="inline-label">Date:</label>
<!-- TODO replace w/ date widget and actual date (problem is that persisted version is "Month day" not an actual date obj -->
<input type="text" class="date" value="<%= updateModel.get('date') %>">
</div>
<div class="row">
<textarea class="new-update-content text-editor"><%= updateModel.get('content') %></textarea>
</div>
<div class="row">
<!-- cid rather than id b/c new ones have cid's not id's -->
<a href="#" class="save-button" name="<%= updateModel.cid %>">Save</a>
<a href="#" class="cancel-button" name="<%= updateModel.cid %>">Cancel</a>
</div>
</form>
<div class="post-preview">
<div class="post-actions">
<a href="#" class="edit-button" name="<%- updateModel.cid %>"><span class="edit-icon"></span>Edit</a>
<a href="#" class="delete-button" name="<%- updateModel.cid %>"><span class="delete-icon"></span>Delete</a>
</div>
<h2>
<span class="calendar-icon"></span><span class="date-display"><%=
updateModel.get('date') %></span>
</h2>
<div class="update-contents"><%= updateModel.get('content') %></div>
</div>
</li>

View File

@@ -0,0 +1,14 @@
<!-- In order to enable better debugging of templates, put them in
the script tag section.
TODO add lazy load fn to load templates as needed (called
from backbone view initialize to set this.template of the view)
-->
<%block name="jsextra">
<script type="text/javascript" charset="utf-8">
// How do I load an html file server side so I can
// Precompiling your templates can be a big help when debugging errors you can't reproduce. This is because precompiled templates can provide line numbers and a stack trace, something that is not possible when compiling templates on the client. The source property is available on the compiled template function for easy precompilation.
// <script>CMS.course_info_update = <%= _.template(jstText).source %>;</script>
</script>
</%block>

View File

@@ -1,2 +1,3 @@
*.js
descriptor
module

View File

@@ -8,72 +8,6 @@ describe "CMS", ->
it "should initialize Views", ->
expect(CMS.Views).toBeDefined()
describe "start", ->
beforeEach ->
@element = $("<div>")
spyOn(CMS.Views, "Course").andReturn(jasmine.createSpyObj("Course", ["render"]))
CMS.start(@element)
it "create the Course", ->
expect(CMS.Views.Course).toHaveBeenCalledWith(el: @element)
expect(CMS.Views.Course().render).toHaveBeenCalled()
describe "view stack", ->
beforeEach ->
@currentView = jasmine.createSpy("currentView")
CMS.viewStack = [@currentView]
describe "replaceView", ->
beforeEach ->
@newView = jasmine.createSpy("newView")
CMS.on("content.show", (@expectedView) =>)
CMS.replaceView(@newView)
it "replace the views on the viewStack", ->
expect(CMS.viewStack).toEqual([@newView])
it "trigger content.show on CMS", ->
expect(@expectedView).toEqual(@newView)
describe "pushView", ->
beforeEach ->
@newView = jasmine.createSpy("newView")
CMS.on("content.show", (@expectedView) =>)
CMS.pushView(@newView)
it "push new view onto viewStack", ->
expect(CMS.viewStack).toEqual([@currentView, @newView])
it "trigger content.show on CMS", ->
expect(@expectedView).toEqual(@newView)
describe "popView", ->
it "remove the current view from the viewStack", ->
CMS.popView()
expect(CMS.viewStack).toEqual([])
describe "when there's no view on the viewStack", ->
beforeEach ->
CMS.viewStack = [@currentView]
CMS.on("content.hide", => @eventTriggered = true)
CMS.popView()
it "trigger content.hide on CMS", ->
expect(@eventTriggered).toBeTruthy
describe "when there's previous view on the viewStack", ->
beforeEach ->
@parentView = jasmine.createSpyObj("parentView", ["delegateEvents"])
CMS.viewStack = [@parentView, @currentView]
CMS.on("content.show", (@expectedView) =>)
CMS.popView()
it "trigger content.show with the previous view on CMS", ->
expect(@expectedView).toEqual @parentView
it "re-bind events on the view", ->
expect(@parentView.delegateEvents).toHaveBeenCalled()
describe "main helper", ->
beforeEach ->
@previousAjaxSettings = $.extend(true, {}, $.ajaxSettings)

View File

@@ -3,75 +3,4 @@ describe "CMS.Models.Module", ->
expect(new CMS.Models.Module().url).toEqual("/save_item")
it "set the correct default", ->
expect(new CMS.Models.Module().defaults).toEqual({data: ""})
describe "loadModule", ->
describe "when the module exists", ->
beforeEach ->
@fakeModule = jasmine.createSpy("fakeModuleObject")
window.FakeModule = jasmine.createSpy("FakeModule").andReturn(@fakeModule)
@module = new CMS.Models.Module(type: "FakeModule")
@stubDiv = $('<div />')
@stubElement = $('<div class="xmodule_edit" />')
@stubElement.data('type', "FakeModule")
@stubDiv.append(@stubElement)
@module.loadModule(@stubDiv)
afterEach ->
window.FakeModule = undefined
it "initialize the module", ->
expect(window.FakeModule).toHaveBeenCalled()
# Need to compare underlying nodes, because jquery selectors
# aren't equal even when they point to the same node.
# http://stackoverflow.com/questions/9505437/how-to-test-jquery-with-jasmine-for-element-id-if-used-as-this
expectedNode = @stubElement[0]
actualNode = window.FakeModule.mostRecentCall.args[0][0]
expect(actualNode).toEqual(expectedNode)
expect(@module.module).toEqual(@fakeModule)
describe "when the module does not exists", ->
beforeEach ->
@previousConsole = window.console
window.console = jasmine.createSpyObj("fakeConsole", ["error"])
@module = new CMS.Models.Module(type: "HTML")
@module.loadModule($("<div>"))
afterEach ->
window.console = @previousConsole
it "print out error to log", ->
expect(window.console.error).toHaveBeenCalled()
expect(window.console.error.mostRecentCall.args[0]).toMatch("^Unable to load")
describe "editUrl", ->
it "construct the correct URL based on id", ->
expect(new CMS.Models.Module(id: "i4x://mit.edu/module/html_123").editUrl())
.toEqual("/edit_item?id=i4x%3A%2F%2Fmit.edu%2Fmodule%2Fhtml_123")
describe "save", ->
beforeEach ->
spyOn(Backbone.Model.prototype, "save")
@module = new CMS.Models.Module()
describe "when the module exists", ->
beforeEach ->
@module.module = jasmine.createSpyObj("FakeModule", ["save"])
@module.module.save.andReturn("module data")
@module.save()
it "set the data and call save on the module", ->
expect(@module.get("data")).toEqual("\"module data\"")
it "call save on the backbone model", ->
expect(Backbone.Model.prototype.save).toHaveBeenCalled()
describe "when the module does not exists", ->
beforeEach ->
@module.save()
it "call save on the backbone model", ->
expect(Backbone.Model.prototype.save).toHaveBeenCalled()
expect(new CMS.Models.Module().defaults).toEqual(undefined)

View File

@@ -1,85 +0,0 @@
describe "CMS.Views.Course", ->
beforeEach ->
setFixtures """
<section id="main-section">
<section class="main-content"></section>
<ol id="weeks">
<li class="cal week-one" style="height: 50px"></li>
<li class="cal week-two" style="height: 100px"></li>
</ol>
</section>
"""
CMS.unbind()
describe "render", ->
beforeEach ->
spyOn(CMS.Views, "Week").andReturn(jasmine.createSpyObj("Week", ["render"]))
new CMS.Views.Course(el: $("#main-section")).render()
it "create week view for each week",->
expect(CMS.Views.Week.calls[0].args[0])
.toEqual({ el: $(".week-one").get(0), height: 101 })
expect(CMS.Views.Week.calls[1].args[0])
.toEqual({ el: $(".week-two").get(0), height: 101 })
describe "on content.show", ->
beforeEach ->
@view = new CMS.Views.Course(el: $("#main-section"))
@subView = jasmine.createSpyObj("subView", ["render"])
@subView.render.andReturn(el: "Subview Content")
spyOn(@view, "contentHeight").andReturn(100)
CMS.trigger("content.show", @subView)
afterEach ->
$("body").removeClass("content")
it "add content class to body", ->
expect($("body").attr("class")).toEqual("content")
it "replace content in .main-content", ->
expect($(".main-content")).toHaveHtml("Subview Content")
it "set height on calendar", ->
expect($(".cal")).toHaveCss(height: "100px")
it "set minimum height on all sections", ->
expect($("#main-section>section")).toHaveCss(minHeight: "100px")
describe "on content.hide", ->
beforeEach ->
$("body").addClass("content")
@view = new CMS.Views.Course(el: $("#main-section"))
$(".cal").css(height: 100)
$("#main-section>section").css(minHeight: 100)
CMS.trigger("content.hide")
afterEach ->
$("body").removeClass("content")
it "remove content class from body", ->
expect($("body").attr("class")).toEqual("")
it "remove content from .main-content", ->
expect($(".main-content")).toHaveHtml("")
it "reset height on calendar", ->
expect($(".cal")).not.toHaveCss(height: "100px")
it "reset minimum height on all sections", ->
expect($("#main-section>section")).not.toHaveCss(minHeight: "100px")
describe "maxWeekHeight", ->
it "return maximum height of the week element", ->
@view = new CMS.Views.Course(el: $("#main-section"))
expect(@view.maxWeekHeight()).toEqual(101)
describe "contentHeight", ->
beforeEach ->
$("body").append($('<header id="test">').height(100).hide())
afterEach ->
$("body>header#test").remove()
it "return the window height minus the header bar", ->
@view = new CMS.Views.Course(el: $("#main-section"))
expect(@view.contentHeight()).toEqual($(window).height() - 100)

View File

@@ -1,81 +1,74 @@
describe "CMS.Views.ModuleEdit", ->
beforeEach ->
@stubModule = jasmine.createSpyObj("Module", ["editUrl", "loadModule"])
spyOn($.fn, "load")
@stubModule = jasmine.createSpy("CMS.Models.Module")
@stubModule.id = 'stub-id'
setFixtures """
<div id="module-edit">
<a href="#" class="save-update">save</a>
<a href="#" class="cancel">cancel</a>
<ol>
<li>
<a href="#" class="module-edit" data-id="i4x://mitx/course/html/module" data-type="html">submodule</a>
</li>
</ol>
<li class="component" id="stub-id">
<div class="component-editor">
<div class="module-editor">
${editor}
</div>
<a href="#" class="save-button">Save</a>
<a href="#" class="cancel-button">Cancel</a>
</div>
""" #"
<div class="component-actions">
<a href="#" class="edit-button"><span class="edit-icon white"></span>Edit</a>
<a href="#" class="delete-button"><span class="delete-icon white"></span>Delete</a>
</div>
<a href="#" class="drag-handle"></a>
<section class="xmodule_display xmodule_stub" data-type="StubModule">
<div id="stub-module-content"/>
</section>
</li>
"""
spyOn($.fn, 'load').andReturn(@moduleData)
@moduleEdit = new CMS.Views.ModuleEdit(
el: $(".component")
model: @stubModule
onDelete: jasmine.createSpy()
)
CMS.unbind()
describe "defaults", ->
it "set the correct tagName", ->
expect(new CMS.Views.ModuleEdit(model: @stubModule).tagName).toEqual("section")
describe "class definition", ->
it "sets the correct tagName", ->
expect(@moduleEdit.tagName).toEqual("li")
it "set the correct className", ->
expect(new CMS.Views.ModuleEdit(model: @stubModule).className).toEqual("edit-pane")
it "sets the correct className", ->
expect(@moduleEdit.className).toEqual("component")
describe "view creation", ->
beforeEach ->
@stubModule.editUrl.andReturn("/edit_item?id=stub_module")
new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule)
describe "methods", ->
describe "initialize", ->
beforeEach ->
spyOn(CMS.Views.ModuleEdit.prototype, 'render')
@moduleEdit = new CMS.Views.ModuleEdit(
el: $(".component")
model: @stubModule
onDelete: jasmine.createSpy()
)
it "load the edit via ajax and pass to the model", ->
expect($.fn.load).toHaveBeenCalledWith("/edit_item?id=stub_module", jasmine.any(Function))
if $.fn.load.mostRecentCall
$.fn.load.mostRecentCall.args[1]()
expect(@stubModule.loadModule).toHaveBeenCalledWith($("#module-edit").get(0))
it "renders the module editor", ->
expect(@moduleEdit.render).toHaveBeenCalled()
describe "save", ->
beforeEach ->
@stubJqXHR = jasmine.createSpy("stubJqXHR")
@stubJqXHR.success = jasmine.createSpy("stubJqXHR.success").andReturn(@stubJqXHR)
@stubJqXHR.error = jasmine.createSpy("stubJqXHR.error").andReturn(@stubJqXHR)
@stubModule.save = jasmine.createSpy("stubModule.save").andReturn(@stubJqXHR)
new CMS.Views.ModuleEdit(el: $(".module-edit"), model: @stubModule)
spyOn(window, "alert")
$(".save-update").click()
describe "render", ->
beforeEach ->
spyOn(@moduleEdit, 'loadDisplay')
spyOn(@moduleEdit, 'delegateEvents')
@moduleEdit.render()
it "call save on the model", ->
expect(@stubModule.save).toHaveBeenCalled()
it "loads the module preview and editor via ajax on the view element", ->
expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/preview_component/#{@moduleEdit.model.id}", jasmine.any(Function))
@moduleEdit.$el.load.mostRecentCall.args[1]()
expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
expect(@moduleEdit.delegateEvents).toHaveBeenCalled()
it "alert user on success", ->
@stubJqXHR.success.mostRecentCall.args[0]()
expect(window.alert).toHaveBeenCalledWith("Your changes have been saved.")
describe "loadDisplay", ->
beforeEach ->
spyOn(XModule, 'loadModule')
@moduleEdit.loadDisplay()
it "alert user on error", ->
@stubJqXHR.error.mostRecentCall.args[0]()
expect(window.alert).toHaveBeenCalledWith("There was an error saving your changes. Please try again.")
describe "cancel", ->
beforeEach ->
spyOn(CMS, "popView")
@view = new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule)
$(".cancel").click()
it "pop current view from viewStack", ->
expect(CMS.popView).toHaveBeenCalled()
describe "editSubmodule", ->
beforeEach ->
@view = new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule)
spyOn(CMS, "pushView")
spyOn(CMS.Views, "ModuleEdit")
.andReturn(@view = jasmine.createSpy("Views.ModuleEdit"))
spyOn(CMS.Models, "Module")
.andReturn(@model = jasmine.createSpy("Models.Module"))
$(".module-edit").click()
it "push another module editing view into viewStack", ->
expect(CMS.pushView).toHaveBeenCalledWith @view
expect(CMS.Views.ModuleEdit).toHaveBeenCalledWith model: @model
expect(CMS.Models.Module).toHaveBeenCalledWith
id: "i4x://mitx/course/html/module"
type: "html"
it "loads the .xmodule-display inside the module editor", ->
expect(XModule.loadModule).toHaveBeenCalled()
expect(XModule.loadModule.mostRecentCall.args[0]).toBe($('.xmodule_display'))

View File

@@ -1,24 +0,0 @@
describe "CMS.Views.Module", ->
beforeEach ->
setFixtures """
<div id="module" data-id="i4x://mitx/course/html/module" data-type="html">
<a href="#" class="module-edit">edit</a>
</div>
"""
describe "edit", ->
beforeEach ->
@view = new CMS.Views.Module(el: $("#module"))
spyOn(CMS, "replaceView")
spyOn(CMS.Views, "ModuleEdit")
.andReturn(@view = jasmine.createSpy("Views.ModuleEdit"))
spyOn(CMS.Models, "Module")
.andReturn(@model = jasmine.createSpy("Models.Module"))
$(".module-edit").click()
it "replace the main view with ModuleEdit view", ->
expect(CMS.replaceView).toHaveBeenCalledWith @view
expect(CMS.Views.ModuleEdit).toHaveBeenCalledWith model: @model
expect(CMS.Models.Module).toHaveBeenCalledWith
id: "i4x://mitx/course/html/module"
type: "html"

View File

@@ -1,7 +0,0 @@
describe "CMS.Views.WeekEdit", ->
describe "defaults", ->
it "set the correct tagName", ->
expect(new CMS.Views.WeekEdit().tagName).toEqual("section")
it "set the correct className", ->
expect(new CMS.Views.WeekEdit().className).toEqual("edit-pane")

View File

@@ -1,67 +0,0 @@
describe "CMS.Views.Week", ->
beforeEach ->
setFixtures """
<div id="week" data-id="i4x://mitx/course/chapter/week">
<div class="editable"></div>
<textarea class="editable-textarea"></textarea>
<a href="#" class="week-edit" >edit</a>
<ul class="modules">
<li id="module-one" class="module"></li>
<li id="module-two" class="module"></li>
</ul>
</div>
"""
CMS.unbind()
describe "render", ->
beforeEach ->
spyOn(CMS.Views, "Module").andReturn(jasmine.createSpyObj("Module", ["render"]))
$.fn.inlineEdit = jasmine.createSpy("$.fn.inlineEdit")
@view = new CMS.Views.Week(el: $("#week"), height: 100).render()
it "set the height of the element", ->
expect(@view.el).toHaveCss(height: "100px")
it "make .editable as inline editor", ->
expect($.fn.inlineEdit.calls[0].object.get(0))
.toEqual($(".editable").get(0))
it "make .editable-test as inline editor", ->
expect($.fn.inlineEdit.calls[1].object.get(0))
.toEqual($(".editable-textarea").get(0))
it "create module subview for each module", ->
expect(CMS.Views.Module.calls[0].args[0])
.toEqual({ el: $("#module-one").get(0) })
expect(CMS.Views.Module.calls[1].args[0])
.toEqual({ el: $("#module-two").get(0) })
describe "edit", ->
beforeEach ->
new CMS.Views.Week(el: $("#week"), height: 100).render()
spyOn(CMS, "replaceView")
spyOn(CMS.Views, "WeekEdit")
.andReturn(@view = jasmine.createSpy("Views.WeekEdit"))
$(".week-edit").click()
it "replace the content with edit week view", ->
expect(CMS.replaceView).toHaveBeenCalledWith @view
expect(CMS.Views.WeekEdit).toHaveBeenCalled()
describe "on content.show", ->
beforeEach ->
@view = new CMS.Views.Week(el: $("#week"), height: 100).render()
@view.$el.height("")
@view.setHeight()
it "set the correct height", ->
expect(@view.el).toHaveCss(height: "100px")
describe "on content.hide", ->
beforeEach ->
@view = new CMS.Views.Week(el: $("#week"), height: 100).render()
@view.$el.height("100px")
@view.resetHeight()
it "remove height from the element", ->
expect(@view.el).not.toHaveCss(height: "100px")

View File

@@ -6,28 +6,6 @@ AjaxPrefix.addAjaxPrefix(jQuery, -> CMS.prefix)
prefix: $("meta[name='path_prefix']").attr('content')
viewStack: []
start: (el) ->
new CMS.Views.Course(el: el).render()
replaceView: (view) ->
@viewStack = [view]
CMS.trigger('content.show', view)
pushView: (view) ->
@viewStack.push(view)
CMS.trigger('content.show', view)
popView: ->
@viewStack.pop()
if _.isEmpty(@viewStack)
CMS.trigger('content.hide')
else
view = _.last(@viewStack)
CMS.trigger('content.show', view)
view.delegateEvents()
_.extend CMS, Backbone.Events
$ ->
@@ -41,7 +19,3 @@ $ ->
navigator.userAgent.match /iPhone|iPod|iPad/i
$('body').addClass 'touch-based-device' if onTouchBasedDevice()
CMS.start($('section.main-container'))

View File

@@ -1,28 +1,2 @@
class CMS.Models.Module extends Backbone.Model
url: '/save_item'
defaults:
data: ''
children: ''
metadata: {}
loadModule: (element) ->
elt = $(element).find('.xmodule_edit').first()
@module = XModule.loadModule(elt)
# find the metadata edit region which should be setup server side,
# so that we can wire up posting back those changes
@metadata_elt = $(element).find('.metadata_edit')
editUrl: ->
"/edit_item?#{$.param(id: @get('id'))}"
save: (args...) ->
@set(data: @module.save()) if @module
# 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
if @metadata_elt
_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_elt)
@set(metadata: _metadata)
super(args...)

View File

@@ -1,5 +0,0 @@
class CMS.Models.NewModule extends Backbone.Model
url: '/clone_item'
newUrl: ->
"/new_item?#{$.param(parent_location: @get('parent_location'))}"

View File

@@ -1,28 +0,0 @@
class CMS.Views.Course extends Backbone.View
initialize: ->
CMS.on('content.show', @showContent)
CMS.on('content.hide', @hideContent)
render: ->
@$('#weeks > li').each (index, week) =>
new CMS.Views.Week(el: week, height: @maxWeekHeight()).render()
return @
showContent: (subview) =>
$('body').addClass('content')
@$('.main-content').html(subview.render().el)
@$('.cal').css height: @contentHeight()
@$('>section').css minHeight: @contentHeight()
hideContent: =>
$('body').removeClass('content')
@$('.main-content').empty()
@$('.cal').css height: ''
@$('>section').css minHeight: ''
maxWeekHeight: ->
weekElementBorderSize = 1
_.max($('#weeks > li').map -> $(this).height()) + weekElementBorderSize
contentHeight: ->
$(window).height() - $('body>header').outerHeight()

View File

@@ -1,14 +0,0 @@
class CMS.Views.Module extends Backbone.View
events:
"click .module-edit": "edit"
edit: (event) =>
event.preventDefault()
previewType = @$el.data('preview-type')
moduleType = @$el.data('type')
CMS.replaceView new CMS.Views.ModuleEdit
model: new CMS.Models.Module
id: @$el.data('id')
type: if moduleType == 'None' then null else moduleType
previewType: if previewType == 'None' then null else previewType

View File

@@ -1,26 +0,0 @@
class CMS.Views.ModuleAdd extends Backbone.View
tagName: 'section'
className: 'add-pane'
events:
'click .cancel': 'cancel'
'click .save': 'save'
initialize: ->
@$el.load @model.newUrl()
save: (event) ->
event.preventDefault()
@model.save({
name: @$el.find('.name').val()
template: $(event.target).data('template-id')
}, {
success: -> CMS.popView()
error: -> alert('Create failed')
})
cancel: (event) ->
event.preventDefault()
CMS.popView()

View File

@@ -1,60 +1,84 @@
class CMS.Views.ModuleEdit extends Backbone.View
tagName: 'section'
className: 'edit-pane'
tagName: 'li'
className: 'component'
events:
'click .cancel': 'cancel'
'click .module-edit': 'editSubmodule'
'click .save-update': 'save'
"click .component-editor .cancel-button": 'clickCancelButton'
"click .component-editor .save-button": 'clickSaveButton'
"click .component-actions .edit-button": 'clickEditButton'
"click .component-actions .delete-button": 'onDelete'
initialize: ->
@$el.load @model.editUrl(), =>
@model.loadModule(@el)
@onDelete = @options.onDelete
@render()
# Load preview modules
XModule.loadModules('display')
@$children = @$el.find('#sortable')
@enableDrag()
$component_editor: => @$el.find('.component-editor')
enableDrag: =>
# Enable dragging things in the #sortable div (if there is one)
if @$children.length > 0
@$children.sortable(
placeholder: "ui-state-highlight"
update: (event, ui) =>
@model.set(children: @$children.find('.module-edit').map(
(idx, el) -> $(el).data('id')
).toArray())
)
@$children.disableSelection()
loadDisplay: ->
XModule.loadModule(@$el.find('.xmodule_display'))
save: (event) =>
event.preventDefault()
@model.save().done((previews) =>
alert("Your changes have been saved.")
previews_section = @$el.find('.previews').empty()
$.each(previews, (idx, preview) =>
preview_wrapper = $('<section/>', class: 'preview').append preview
previews_section.append preview_wrapper
)
loadEdit: ->
if not @module
@module = XModule.loadModule(@$el.find('.xmodule_edit'))
XModule.loadModules('display')
).fail( ->
alert("There was an error saving your changes. Please try again.")
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
cloneTemplate: (parent, template) ->
$.post("/clone_item", {
parent_location: parent
template: template
}, (data) =>
@model.set(id: data.id)
@$el.data('id', data.id)
@render()
)
cancel: (event) ->
event.preventDefault()
CMS.popView()
@enableDrag()
render: ->
if @model.id
@$el.load("/preview_component/#{@model.id}", =>
@loadDisplay()
@delegateEvents()
)
editSubmodule: (event) ->
clickSaveButton: (event) =>
event.preventDefault()
previewType = $(event.target).data('preview-type')
moduleType = $(event.target).data('type')
CMS.pushView new CMS.Views.ModuleEdit
model: new CMS.Models.Module
id: $(event.target).data('id')
type: if moduleType == 'None' then null else moduleType
previewType: if previewType == 'None' then null else previewType
@enableDrag()
data = @module.save()
data.metadata = _.extend(data.metadata || {}, @metadata())
@hideModal()
@model.save(data).done( =>
# # showToastMessage("Your changes have been saved.", null, 3)
@module = null
@render()
@$el.removeClass('editing')
).fail( ->
showToastMessage("There was an error saving your changes. Please try again.", null, 3)
)
clickCancelButton: (event) ->
event.preventDefault()
@$el.removeClass('editing')
@$component_editor().slideUp(150)
@hideModal()
hideModal: ->
$modalCover.hide()
$modalCover.removeClass('is-fixed')
clickEditButton: (event) ->
event.preventDefault()
@$el.addClass('editing')
$modalCover.show().addClass('is-fixed')
@$component_editor().slideDown(150)
@loadEdit()

View File

@@ -0,0 +1,58 @@
class CMS.Views.TabsEdit extends Backbone.View
events:
'click .new-tab': 'addNewTab'
initialize: =>
@$('.component').each((idx, element) =>
new CMS.Views.ModuleEdit(
el: element,
onDelete: @deleteTab,
model: new CMS.Models.Module(
id: $(element).data('id'),
)
)
)
@$('.components').sortable(
handle: '.drag-handle'
update: (event, ui) => alert 'not yet implemented!'
helper: 'clone'
opacity: '0.5'
placeholder: 'component-placeholder'
forcePlaceholderSize: true
axis: 'y'
items: '> .component'
)
addNewTab: (event) =>
event.preventDefault()
editor = new CMS.Views.ModuleEdit(
onDelete: @deleteTab
model: new CMS.Models.Module()
)
$('.new-component-item').before(editor.$el)
editor.$el.addClass('new')
setTimeout(=>
editor.$el.removeClass('new')
, 500)
editor.cloneTemplate(
@model.get('id'),
'i4x://edx/templates/static_tab/Empty'
)
deleteTab: (event) =>
if not confirm 'Are you sure you want to delete this component? This action cannot be undone.'
return
$component = $(event.currentTarget).parents('.component')
$.post('/delete_item', {
id: $component.data('id')
}, =>
$component.remove()
)

View File

@@ -0,0 +1,219 @@
class CMS.Views.UnitEdit extends Backbone.View
events:
'click .new-component .new-component-type a': 'showComponentTemplates'
'click .new-component .cancel-button': 'closeNewComponent'
'click .new-component-templates .new-component-template a': 'saveNewComponent'
'click .new-component-templates .cancel-button': 'closeNewComponent'
'click .delete-draft': 'deleteDraft'
'click .create-draft': 'createDraft'
'click .publish-draft': 'publishDraft'
'change .visibility-select': 'setVisibility'
initialize: =>
@visibilityView = new CMS.Views.UnitEdit.Visibility(
el: @$('.visibility-select')
model: @model
)
@locationView = new CMS.Views.UnitEdit.LocationState(
el: @$('.section-item.editing a')
model: @model
)
@nameView = new CMS.Views.UnitEdit.NameEdit(
el: @$('.unit-name-input')
model: @model
)
@model.on('change:state', @render)
@$newComponentItem = @$('.new-component-item')
@$newComponentTypePicker = @$('.new-component')
@$newComponentTemplatePickers = @$('.new-component-templates')
@$newComponentButton = @$('.new-component-button')
@$('.components').sortable(
handle: '.drag-handle'
update: (event, ui) => @model.save(children: @components())
helper: 'clone'
opacity: '0.5'
placeholder: 'component-placeholder'
forcePlaceholderSize: true
axis: 'y'
items: '> .component'
)
@$('.component').each((idx, element) =>
new CMS.Views.ModuleEdit(
el: element,
onDelete: @deleteComponent,
model: new CMS.Models.Module(
id: $(element).data('id'),
)
)
)
showComponentTemplates: (event) =>
event.preventDefault()
type = $(event.currentTarget).data('type')
@$newComponentTypePicker.slideUp(250)
@$(".new-component-#{type}").slideDown(250)
$('html, body').animate({
scrollTop: @$(".new-component-#{type}").offset().top
}, 500)
closeNewComponent: (event) =>
event.preventDefault()
@$newComponentTypePicker.slideDown(250)
@$newComponentTemplatePickers.slideUp(250)
@$newComponentItem.removeClass('adding')
@$newComponentItem.find('.rendered-component').remove()
saveNewComponent: (event) =>
event.preventDefault()
editor = new CMS.Views.ModuleEdit(
onDelete: @deleteComponent
model: new CMS.Models.Module()
)
@$newComponentItem.before(editor.$el)
editor.cloneTemplate(
@$el.data('id'),
$(event.currentTarget).data('location')
)
@closeNewComponent(event)
components: => @$('.component').map((idx, el) -> $(el).data('id')).get()
wait: (value) =>
@$('.unit-body').toggleClass("waiting", value)
render: =>
if @model.hasChanged('state')
@$el.toggleClass("edit-state-#{@model.previous('state')} edit-state-#{@model.get('state')}")
@wait(false)
saveDraft: =>
@model.save()
deleteComponent: (event) =>
if not confirm 'Are you sure you want to delete this component? This action cannot be undone.'
return
$component = $(event.currentTarget).parents('.component')
$.post('/delete_item', {
id: $component.data('id')
}, =>
$component.remove()
@model.save(children: @components())
)
deleteDraft: (event) ->
@wait(true)
$.post('/delete_item', {
id: @$el.data('id')
delete_children: true
}, =>
window.location.reload()
)
createDraft: (event) ->
@wait(true)
$.post('/create_draft', {
id: @$el.data('id')
}, =>
@model.set('state', 'draft')
)
publishDraft: (event) ->
@wait(true)
@saveDraft()
$.post('/publish_draft', {
id: @$el.data('id')
}, =>
@model.set('state', 'public')
)
setVisibility: (event) ->
if @$('.visibility-select').val() == 'private'
target_url = '/unpublish_unit'
else
target_url = '/publish_draft'
@wait(true)
$.post(target_url, {
id: @$el.data('id')
}, =>
@model.set('state', @$('.visibility-select').val())
)
class CMS.Views.UnitEdit.NameEdit extends Backbone.View
events:
"keyup .unit-display-name-input": "saveName"
initialize: =>
@model.on('change:metadata', @render)
@model.on('change:state', @setEnabled)
@setEnabled()
@saveName
@$spinner = $('<span class="spinner-in-field-icon"></span>');
render: =>
@$('.unit-display-name-input').val(@model.get('metadata').display_name)
setEnabled: =>
disabled = @model.get('state') == 'public'
if disabled
@$('.unit-display-name-input').attr('disabled', true)
else
@$('.unit-display-name-input').removeAttr('disabled')
saveName: =>
# Treat the metadata dictionary as immutable
metadata = $.extend({}, @model.get('metadata'))
metadata.display_name = @$('.unit-display-name-input').val()
$('.unit-location .editing .unit-name').html(metadata.display_name)
inputField = this.$el.find('input')
# add a spinner
@$spinner.css({
'position': 'absolute',
'top': Math.floor(inputField.position().top + (inputField.outerHeight() / 2) + 3),
'left': inputField.position().left + inputField.outerWidth() - 24,
'margin-top': '-10px'
});
inputField.after(@$spinner);
@$spinner.fadeIn(10)
# save the name after a slight delay
if @timer
clearTimeout @timer
@timer = setTimeout( =>
@model.save(metadata: metadata)
@timer = null
@$spinner.delay(500).fadeOut(150)
, 500)
class CMS.Views.UnitEdit.LocationState extends Backbone.View
initialize: =>
@model.on('change:state', @render)
render: =>
@$el.toggleClass("#{@model.previous('state')}-item #{@model.get('state')}-item")
class CMS.Views.UnitEdit.Visibility extends Backbone.View
initialize: =>
@model.on('change:state', @render)
@render()
render: =>
@$el.val(@model.get('state'))

View File

@@ -1,32 +0,0 @@
class CMS.Views.Week extends Backbone.View
events:
'click .week-edit': 'edit'
'click .new-module': 'new'
initialize: ->
CMS.on('content.show', @resetHeight)
CMS.on('content.hide', @setHeight)
render: ->
@setHeight()
@$('.editable').inlineEdit()
@$('.editable-textarea').inlineEdit(control: 'textarea')
@$('.modules .module').each ->
new CMS.Views.Module(el: this).render()
return @
edit: (event) ->
event.preventDefault()
CMS.replaceView(new CMS.Views.WeekEdit())
setHeight: =>
@$el.height(@options.height)
resetHeight: =>
@$el.height('')
new: (event) =>
event.preventDefault()
CMS.replaceView new CMS.Views.ModuleAdd
model: new CMS.Models.NewModule
parent_location: @$el.data('id')

View File

@@ -1,3 +0,0 @@
class CMS.Views.WeekEdit extends Backbone.View
tagName: 'section'
className: 'edit-pane'

View File

@@ -0,0 +1,89 @@
.mceContentBody {
padding: 10px;
background-color: #fff;
font-family: 'Open Sans', Verdana, Arial, Helvetica, sans-serif;
font-size: 16px;
line-height: 1.6;
color: #3c3c3c;
scrollbar-3dlight-color: #F0F0EE;
scrollbar-arrow-color: #676662;
scrollbar-base-color: #F0F0EE;
scrollbar-darkshadow-color: #DDDDDD;
scrollbar-face-color: #E0E0DD;
scrollbar-highlight-color: #F0F0EE;
scrollbar-shadow-color: #F0F0EE;
scrollbar-track-color: #F5F5F5;
}
h1 {
color: #3c3c3c;
font-weight: normal;
font-size: 2em;
line-height: 1.4em;
letter-spacing: 1px;
}
h2 {
color: #646464;
font-weight: normal;
font-size: 1.2em;
line-height: 1.2em;
letter-spacing: 1px;
margin-bottom: 15px;
text-transform: uppercase;
-webkit-font-smoothing: antialiased;
}
h3 {
font-size: 1.2em;
font-weight: 600;
}
p {
margin-bottom: 1.416em;
font-size: 1em;
line-height: 1.6em !important;
color: $baseFontColor;
}
em, i {
font-style: italic;
}
strong, b {
font-style: bold;
}
p + p, ul + p, ol + p {
margin-top: 20px;
}
ol, ul {
margin: 1em 0;
padding: 0 0 0 1em;
}
ol li, ul li {
margin-bottom: 0.708em;
}
ol {
list-style: decimal outside none;
}
ul {
list-style: disc outside none;
}
a, a:link, a:visited, a:hover, a:active {
color: #1d9dd9;
}
img {
max-width: 100%;
}
code {
font-family: monospace, serif;
background: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Some files were not shown because too many files have changed in this diff Show More