diff --git a/.gitignore b/.gitignore index 3b7223108b..2fd1ca0181 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ lms/lib/comment_client/python nosetests.xml cover_html/ .idea/ +chromedriver.log \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..253bae3686 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "common/test/phantom-jasmine"] + path = common/test/phantom-jasmine + url = https://github.com/jcarver989/phantom-jasmine.git \ No newline at end of file diff --git a/Gemfile b/Gemfile index 9ad08c7adb..0fe7df217d 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,5 @@ ruby "1.9.3" gem 'rake' gem 'sass', '3.1.15' gem 'bourbon', '~> 1.3.6' +gem 'colorize' +gem 'launchy' diff --git a/cms/CHANGELOG.md b/cms/CHANGELOG.md new file mode 100644 index 0000000000..d21d08d23c --- /dev/null +++ b/cms/CHANGELOG.md @@ -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 diff --git a/cms/djangoapps/github_sync/management/__init__.py b/cms/djangoapps/__init__.py similarity index 100% rename from cms/djangoapps/github_sync/management/__init__.py rename to cms/djangoapps/__init__.py diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index fec25c5ba2..22bbc4bc1c 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -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 _ + # if it exists, then use that one, otherwise use a _ 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 diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py new file mode 100644 index 0000000000..6995df06a8 --- /dev/null +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -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" : "
    [
  1. date

    content
  2. ]
"} "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("
    ") + + # Confirm that root is
      , iterate over
    1. , pull out

      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("
        ") + + # No try/catch b/c failure generates an error back to client + new_html_parsed = html.fromstring('
      1. ' + update['date'] + '

        ' + update['content'] + '
      2. ') + + # Confirm that root is
          , iterate over
        1. , pull out

          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("
            ") + + 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)) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py new file mode 100644 index 0000000000..d910d73085 --- /dev/null +++ b/cms/djangoapps/contentstore/features/common.py @@ -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) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/courses.feature b/cms/djangoapps/contentstore/features/courses.feature new file mode 100644 index 0000000000..39d39b50aa --- /dev/null +++ b/cms/djangoapps/contentstore/features/courses.feature @@ -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 \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py new file mode 100644 index 0000000000..2c1cf6281a --- /dev/null +++ b/cms/djangoapps/contentstore/features/courses.py @@ -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') diff --git a/cms/djangoapps/contentstore/features/factories.py b/cms/djangoapps/contentstore/features/factories.py new file mode 100644 index 0000000000..389f2bac49 --- /dev/null +++ b/cms/djangoapps/contentstore/features/factories.py @@ -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() \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature new file mode 100644 index 0000000000..ad00ba2911 --- /dev/null +++ b/cms/djangoapps/contentstore/features/section.feature @@ -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 \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py new file mode 100644 index 0000000000..8ac30e2170 --- /dev/null +++ b/cms/djangoapps/contentstore/features/section.py @@ -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' diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature new file mode 100644 index 0000000000..8a6f93d33b --- /dev/null +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -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." \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py new file mode 100644 index 0000000000..7794511f94 --- /dev/null +++ b/cms/djangoapps/contentstore/features/signup.py @@ -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) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature new file mode 100644 index 0000000000..5276b90d12 --- /dev/null +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -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 \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py new file mode 100644 index 0000000000..010678c0e8 --- /dev/null +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -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) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature new file mode 100644 index 0000000000..5acb5bfe44 --- /dev/null +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py new file mode 100644 index 0000000000..ea614d3feb --- /dev/null +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -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) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/management/commands/clone.py b/cms/djangoapps/contentstore/management/commands/clone.py new file mode 100644 index 0000000000..2357cd1dbd --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/clone.py @@ -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_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) diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py new file mode 100644 index 0000000000..0313f7faed --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -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: ") + + 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) + + + diff --git a/cms/djangoapps/contentstore/management/commands/export.py b/cms/djangoapps/contentstore/management/commands/export.py new file mode 100644 index 0000000000..11b043c2ab --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -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_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) diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index 1d15f1e7df..2a040f35b6 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -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) diff --git a/cms/djangoapps/contentstore/management/commands/prompt.py b/cms/djangoapps/contentstore/management/commands/prompt.py new file mode 100644 index 0000000000..9c8fd81d45 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/prompt.py @@ -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 . + 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") \ No newline at end of file diff --git a/cms/djangoapps/contentstore/management/commands/xlint.py b/cms/djangoapps/contentstore/management/commands/xlint.py new file mode 100644 index 0000000000..6bc254a1ff --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/xlint.py @@ -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_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) diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py new file mode 100644 index 0000000000..0017010885 --- /dev/null +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -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) diff --git a/cms/djangoapps/contentstore/tests/factories.py b/cms/djangoapps/contentstore/tests/factories.py new file mode 100644 index 0000000000..cb9f451d38 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/factories.py @@ -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' \ No newline at end of file diff --git a/cms/djangoapps/contentstore/tests/test_core_caching.py b/cms/djangoapps/contentstore/tests/test_core_caching.py new file mode 100644 index 0000000000..0cb4a4930c --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_core_caching.py @@ -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') + + + + diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py new file mode 100644 index 0000000000..74eff6e9cc --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -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 = "bar" + # 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, '
          1. Course Details
          2. ', 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") + + diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py new file mode 100644 index 0000000000..96e4468b31 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -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 = '' + 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

            p

            ' + payload['content'] = content + resp = self.client.post(url, json.dumps(payload), "application/json") + + self.assertHTMLEqual(content, json.loads(resp.content)['content'], "iframe w/ div") diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py new file mode 100644 index 0000000000..13f6189cc5 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -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") diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 429774c91e..2597ac64fd 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -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, + '

            My Courses

            ', + 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, + 'Robot Super Educational Course', + 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, + 'Robot Super Course', + 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") \ No newline at end of file diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index fc801ac684..da2993e463 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 425b29f8bc..816ccab091 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -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 - # .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' : '' + }) diff --git a/cms/djangoapps/github_sync/__init__.py b/cms/djangoapps/github_sync/__init__.py deleted file mode 100644 index a4dbe29fb6..0000000000 --- a/cms/djangoapps/github_sync/__init__.py +++ /dev/null @@ -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 ") - - -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) - )) diff --git a/cms/djangoapps/github_sync/exceptions.py b/cms/djangoapps/github_sync/exceptions.py deleted file mode 100644 index 1fe8d1d73e..0000000000 --- a/cms/djangoapps/github_sync/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class GithubSyncError(Exception): - pass - - -class InvalidRepo(Exception): - pass diff --git a/cms/djangoapps/github_sync/management/commands/sync_with_github.py b/cms/djangoapps/github_sync/management/commands/sync_with_github.py deleted file mode 100644 index 4383871df3..0000000000 --- a/cms/djangoapps/github_sync/management/commands/sync_with_github.py +++ /dev/null @@ -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() diff --git a/cms/djangoapps/github_sync/tests/__init__.py b/cms/djangoapps/github_sync/tests/__init__.py deleted file mode 100644 index e2b9a909a7..0000000000 --- a/cms/djangoapps/github_sync/tests/__init__.py +++ /dev/null @@ -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) diff --git a/cms/djangoapps/github_sync/tests/test_views.py b/cms/djangoapps/github_sync/tests/test_views.py deleted file mode 100644 index 37030d6a1b..0000000000 --- a/cms/djangoapps/github_sync/tests/test_views.py +++ /dev/null @@ -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')) diff --git a/cms/djangoapps/github_sync/views.py b/cms/djangoapps/github_sync/views.py deleted file mode 100644 index c3b5172b29..0000000000 --- a/cms/djangoapps/github_sync/views.py +++ /dev/null @@ -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') diff --git a/cms/djangoapps/github_sync/management/commands/__init__.py b/cms/djangoapps/models/__init__.py similarity index 100% rename from cms/djangoapps/github_sync/management/commands/__init__.py rename to cms/djangoapps/models/__init__.py diff --git a/lms/djangoapps/heartbeat/__init__.py b/cms/djangoapps/models/settings/__init__.py similarity index 100% rename from lms/djangoapps/heartbeat/__init__.py rename to cms/djangoapps/models/settings/__init__.py diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py new file mode 100644 index 0000000000..d01e784d74 --- /dev/null +++ b/cms/djangoapps/models/settings/course_details.py @@ -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('' + 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) diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py new file mode 100644 index 0000000000..9cfa18c8c9 --- /dev/null +++ b/cms/djangoapps/models/settings/course_grading.py @@ -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 diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py new file mode 100644 index 0000000000..5bc9b53fc4 --- /dev/null +++ b/cms/envs/acceptance.py @@ -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 diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 34312eb25b..b44baacb0b 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -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'] diff --git a/cms/envs/common.py b/cms/envs/common.py index d80c705fed..c047d689ce 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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', ) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index dd0e0337f6..e29ee62e20 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -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' diff --git a/cms/envs/dev_ike.py b/cms/envs/dev_ike.py new file mode 100644 index 0000000000..5fb120854b --- /dev/null +++ b/cms/envs/dev_ike.py @@ -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 + + diff --git a/cms/envs/jasmine.py b/cms/envs/jasmine.py new file mode 100644 index 0000000000..5c9be1cf9c --- /dev/null +++ b/cms/envs/jasmine.py @@ -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', ) diff --git a/cms/envs/logsettings.py b/cms/envs/logsettings.py deleted file mode 100644 index 3683314d02..0000000000 --- a/cms/envs/logsettings.py +++ /dev/null @@ -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 - }, - } - } diff --git a/cms/envs/test.py b/cms/envs/test.py index d55c309827..d9a2597cbb 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -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', +) \ No newline at end of file diff --git a/cms/static/client_templates/course_grade_policy.html b/cms/static/client_templates/course_grade_policy.html new file mode 100644 index 0000000000..c9a21280dd --- /dev/null +++ b/cms/static/client_templates/course_grade_policy.html @@ -0,0 +1,69 @@ +
          3. +
            + + +
            +
            + + e.g. Homework, Labs, Midterm Exams, Final Exam +
            +
            +
            + +
            + + +
            +
            + + e.g. HW, Midterm, Final +
            +
            +
            + +
            + + +
            +
            + + e.g. 25% +
            +
            +
            + +
            + + +
            +
            + + total exercises assigned +
            +
            +
            + +
            + + +
            +
            + + total exercises that won't be graded +
            +
            +
            + Delete +
          4. diff --git a/cms/static/client_templates/course_info_handouts.html b/cms/static/client_templates/course_info_handouts.html new file mode 100644 index 0000000000..958a1c77d6 --- /dev/null +++ b/cms/static/client_templates/course_info_handouts.html @@ -0,0 +1,19 @@ +Edit + +

            Course Handouts

            +<%if (model.get('data') != null) { %> +
            + <%= model.get('data') %> +
            +<% } else {%> +

            You have no handouts defined

            +<% } %> +
            +
            + +
            +
            + Save + Cancel +
            +
            diff --git a/cms/static/client_templates/course_info_update.html b/cms/static/client_templates/course_info_update.html new file mode 100644 index 0000000000..79775db5e3 --- /dev/null +++ b/cms/static/client_templates/course_info_update.html @@ -0,0 +1,29 @@ +
          5. + +
            +
            + + + +
            +
            + +
            +
            + + Save + Cancel +
            +
            +
            +
            + Edit + Delete +
            +

            + <%= + updateModel.get('date') %> +

            +
            <%= updateModel.get('content') %>
            +
            +
          6. \ No newline at end of file diff --git a/cms/static/client_templates/load_templates.html b/cms/static/client_templates/load_templates.html new file mode 100644 index 0000000000..3ff88d6fe5 --- /dev/null +++ b/cms/static/client_templates/load_templates.html @@ -0,0 +1,14 @@ + + +<%block name="jsextra"> + + + + \ No newline at end of file diff --git a/cms/static/coffee/.gitignore b/cms/static/coffee/.gitignore index bb90193362..e114474f98 100644 --- a/cms/static/coffee/.gitignore +++ b/cms/static/coffee/.gitignore @@ -1,2 +1,3 @@ *.js +descriptor module diff --git a/cms/static/coffee/spec/main_spec.coffee b/cms/static/coffee/spec/main_spec.coffee index 72800cec7f..8b2fa52866 100644 --- a/cms/static/coffee/spec/main_spec.coffee +++ b/cms/static/coffee/spec/main_spec.coffee @@ -8,72 +8,6 @@ describe "CMS", -> it "should initialize Views", -> expect(CMS.Views).toBeDefined() - describe "start", -> - beforeEach -> - @element = $("
            ") - 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) diff --git a/cms/static/coffee/spec/models/module_spec.coffee b/cms/static/coffee/spec/models/module_spec.coffee index 8fd552d93c..5fd447539f 100644 --- a/cms/static/coffee/spec/models/module_spec.coffee +++ b/cms/static/coffee/spec/models/module_spec.coffee @@ -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 = $('
            ') - @stubElement = $('
            ') - @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($("
            ")) - - 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) diff --git a/cms/static/coffee/spec/views/course_spec.coffee b/cms/static/coffee/spec/views/course_spec.coffee deleted file mode 100644 index f6a430ac2d..0000000000 --- a/cms/static/coffee/spec/views/course_spec.coffee +++ /dev/null @@ -1,85 +0,0 @@ -describe "CMS.Views.Course", -> - beforeEach -> - setFixtures """ -
            -
            -
              -
            1. -
            2. -
            -
            - """ - 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($('
            ').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) diff --git a/cms/static/coffee/spec/views/module_edit_spec.coffee b/cms/static/coffee/spec/views/module_edit_spec.coffee index 067d169bca..5e83ecb42d 100644 --- a/cms/static/coffee/spec/views/module_edit_spec.coffee +++ b/cms/static/coffee/spec/views/module_edit_spec.coffee @@ -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 """ -
            - save - cancel -
              -
            1. - submodule -
            2. -
            +
          7. +
            +
            + ${editor} +
            + Save + Cancel
            - """ #" +
            + Edit + Delete +
            + +
            +
            +
            +
          8. + """ + 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')) diff --git a/cms/static/coffee/spec/views/module_spec.coffee b/cms/static/coffee/spec/views/module_spec.coffee deleted file mode 100644 index 826263bc41..0000000000 --- a/cms/static/coffee/spec/views/module_spec.coffee +++ /dev/null @@ -1,24 +0,0 @@ -describe "CMS.Views.Module", -> - beforeEach -> - setFixtures """ -
            - edit -
            - """ - - 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" diff --git a/cms/static/coffee/spec/views/week_edit_spec.coffee b/cms/static/coffee/spec/views/week_edit_spec.coffee deleted file mode 100644 index 754474d77f..0000000000 --- a/cms/static/coffee/spec/views/week_edit_spec.coffee +++ /dev/null @@ -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") diff --git a/cms/static/coffee/spec/views/week_spec.coffee b/cms/static/coffee/spec/views/week_spec.coffee deleted file mode 100644 index d5256b0a57..0000000000 --- a/cms/static/coffee/spec/views/week_spec.coffee +++ /dev/null @@ -1,67 +0,0 @@ -describe "CMS.Views.Week", -> - beforeEach -> - setFixtures """ -
            -
            - - edit -
              -
            • -
            • -
            -
            - """ - 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") diff --git a/cms/static/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index 57b6d1ae93..8c23d6ac99 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -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')) - diff --git a/cms/static/coffee/src/models/module.coffee b/cms/static/coffee/src/models/module.coffee index 52357795ed..2a1fcc785d 100644 --- a/cms/static/coffee/src/models/module.coffee +++ b/cms/static/coffee/src/models/module.coffee @@ -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...) diff --git a/cms/static/coffee/src/models/new_module.coffee b/cms/static/coffee/src/models/new_module.coffee deleted file mode 100644 index 58a109225e..0000000000 --- a/cms/static/coffee/src/models/new_module.coffee +++ /dev/null @@ -1,5 +0,0 @@ -class CMS.Models.NewModule extends Backbone.Model - url: '/clone_item' - - newUrl: -> - "/new_item?#{$.param(parent_location: @get('parent_location'))}" diff --git a/cms/static/coffee/src/views/course.coffee b/cms/static/coffee/src/views/course.coffee deleted file mode 100644 index 2a5a012c07..0000000000 --- a/cms/static/coffee/src/views/course.coffee +++ /dev/null @@ -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() diff --git a/cms/static/coffee/src/views/module.coffee b/cms/static/coffee/src/views/module.coffee deleted file mode 100644 index 1b9e39e8c2..0000000000 --- a/cms/static/coffee/src/views/module.coffee +++ /dev/null @@ -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 - diff --git a/cms/static/coffee/src/views/module_add.coffee b/cms/static/coffee/src/views/module_add.coffee deleted file mode 100644 index f379174c77..0000000000 --- a/cms/static/coffee/src/views/module_add.coffee +++ /dev/null @@ -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() - - diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index 2c4eb26eff..9f7e3a5e60 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -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 = $('
            ', 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() diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee new file mode 100644 index 0000000000..1fbc6ffa7f --- /dev/null +++ b/cms/static/coffee/src/views/tabs.coffee @@ -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() + ) + + + + diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee new file mode 100644 index 0000000000..7f5fa4adce --- /dev/null +++ b/cms/static/coffee/src/views/unit.coffee @@ -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 = $(''); + + 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')) diff --git a/cms/static/coffee/src/views/week.coffee b/cms/static/coffee/src/views/week.coffee deleted file mode 100644 index e2b5a50d59..0000000000 --- a/cms/static/coffee/src/views/week.coffee +++ /dev/null @@ -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') diff --git a/cms/static/coffee/src/views/week_edit.coffee b/cms/static/coffee/src/views/week_edit.coffee deleted file mode 100644 index 3082bc9fe2..0000000000 --- a/cms/static/coffee/src/views/week_edit.coffee +++ /dev/null @@ -1,3 +0,0 @@ -class CMS.Views.WeekEdit extends Backbone.View - tagName: 'section' - className: 'edit-pane' diff --git a/cms/static/css/tiny-mce.css b/cms/static/css/tiny-mce.css new file mode 100644 index 0000000000..63d2bada94 --- /dev/null +++ b/cms/static/css/tiny-mce.css @@ -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; +} \ No newline at end of file diff --git a/cms/static/img/blue-spinner.gif b/cms/static/img/blue-spinner.gif new file mode 100644 index 0000000000..2cee72553a Binary files /dev/null and b/cms/static/img/blue-spinner.gif differ diff --git a/cms/static/img/breadcrumb-arrow.png b/cms/static/img/breadcrumb-arrow.png new file mode 100644 index 0000000000..5dca714363 Binary files /dev/null and b/cms/static/img/breadcrumb-arrow.png differ diff --git a/cms/static/img/calendar-icon.png b/cms/static/img/calendar-icon.png new file mode 100644 index 0000000000..5d30a27c01 Binary files /dev/null and b/cms/static/img/calendar-icon.png differ diff --git a/cms/static/img/choice-example.png b/cms/static/img/choice-example.png new file mode 100644 index 0000000000..ee136577a9 Binary files /dev/null and b/cms/static/img/choice-example.png differ diff --git a/cms/static/img/close-icon.png b/cms/static/img/close-icon.png new file mode 100644 index 0000000000..684399725b Binary files /dev/null and b/cms/static/img/close-icon.png differ diff --git a/cms/static/img/collapse-all-icon.png b/cms/static/img/collapse-all-icon.png new file mode 100644 index 0000000000..c468778b02 Binary files /dev/null and b/cms/static/img/collapse-all-icon.png differ diff --git a/cms/static/img/date-circle.png b/cms/static/img/date-circle.png new file mode 100644 index 0000000000..10b654735d Binary files /dev/null and b/cms/static/img/date-circle.png differ diff --git a/cms/static/img/delete-icon-white.png b/cms/static/img/delete-icon-white.png new file mode 100644 index 0000000000..3a1efd1f97 Binary files /dev/null and b/cms/static/img/delete-icon-white.png differ diff --git a/cms/static/img/delete-icon.png b/cms/static/img/delete-icon.png new file mode 100644 index 0000000000..9c7f65daef Binary files /dev/null and b/cms/static/img/delete-icon.png differ diff --git a/cms/static/img/discussion-module.png b/cms/static/img/discussion-module.png new file mode 100644 index 0000000000..1eed318e57 Binary files /dev/null and b/cms/static/img/discussion-module.png differ diff --git a/cms/static/img/drag-handles.png b/cms/static/img/drag-handles.png new file mode 100644 index 0000000000..391a64dbe0 Binary files /dev/null and b/cms/static/img/drag-handles.png differ diff --git a/cms/static/img/due-date-icon.png b/cms/static/img/due-date-icon.png new file mode 100644 index 0000000000..294837a1df Binary files /dev/null and b/cms/static/img/due-date-icon.png differ diff --git a/cms/static/img/dummy-calendar.png b/cms/static/img/dummy-calendar.png new file mode 100644 index 0000000000..4720877235 Binary files /dev/null and b/cms/static/img/dummy-calendar.png differ diff --git a/cms/static/img/edit-icon-white.png b/cms/static/img/edit-icon-white.png new file mode 100644 index 0000000000..0469d56c89 Binary files /dev/null and b/cms/static/img/edit-icon-white.png differ diff --git a/cms/static/img/edit-icon.png b/cms/static/img/edit-icon.png new file mode 100644 index 0000000000..748d3d2115 Binary files /dev/null and b/cms/static/img/edit-icon.png differ diff --git a/cms/static/img/edx-labs-logo-small.png b/cms/static/img/edx-labs-logo-small.png new file mode 100644 index 0000000000..992e158148 Binary files /dev/null and b/cms/static/img/edx-labs-logo-small.png differ diff --git a/cms/static/img/edx-studio-large.png b/cms/static/img/edx-studio-large.png new file mode 100644 index 0000000000..d3ea3382a8 Binary files /dev/null and b/cms/static/img/edx-studio-large.png differ diff --git a/cms/static/img/edx-studio-logo-small.png b/cms/static/img/edx-studio-logo-small.png new file mode 100644 index 0000000000..728a3f81e0 Binary files /dev/null and b/cms/static/img/edx-studio-logo-small.png differ diff --git a/cms/static/img/expand-collapse-icons.png b/cms/static/img/expand-collapse-icons.png new file mode 100644 index 0000000000..a4a1518ec9 Binary files /dev/null and b/cms/static/img/expand-collapse-icons.png differ diff --git a/cms/static/img/explanation-example.png b/cms/static/img/explanation-example.png new file mode 100644 index 0000000000..94db245515 Binary files /dev/null and b/cms/static/img/explanation-example.png differ diff --git a/cms/static/img/file-icon.png b/cms/static/img/file-icon.png new file mode 100644 index 0000000000..b054232f76 Binary files /dev/null and b/cms/static/img/file-icon.png differ diff --git a/cms/static/img/folder-icon.png b/cms/static/img/folder-icon.png new file mode 100644 index 0000000000..a98a6b995e Binary files /dev/null and b/cms/static/img/folder-icon.png differ diff --git a/cms/static/img/header-example.png b/cms/static/img/header-example.png new file mode 100644 index 0000000000..732e816a15 Binary files /dev/null and b/cms/static/img/header-example.png differ diff --git a/cms/static/img/home-icon-blue.png b/cms/static/img/home-icon-blue.png new file mode 100644 index 0000000000..45b4971a2a Binary files /dev/null and b/cms/static/img/home-icon-blue.png differ diff --git a/cms/static/img/home-icon.png b/cms/static/img/home-icon.png new file mode 100644 index 0000000000..be44bc2089 Binary files /dev/null and b/cms/static/img/home-icon.png differ diff --git a/cms/static/img/html-icon.png b/cms/static/img/html-icon.png new file mode 100644 index 0000000000..e739f2fc11 Binary files /dev/null and b/cms/static/img/html-icon.png differ diff --git a/cms/static/img/large-discussion-icon.png b/cms/static/img/large-discussion-icon.png new file mode 100644 index 0000000000..2f0bfea98f Binary files /dev/null and b/cms/static/img/large-discussion-icon.png differ diff --git a/cms/static/img/large-freeform-icon.png b/cms/static/img/large-freeform-icon.png new file mode 100644 index 0000000000..b1d195a7ca Binary files /dev/null and b/cms/static/img/large-freeform-icon.png differ diff --git a/cms/static/img/large-problem-icon.png b/cms/static/img/large-problem-icon.png new file mode 100644 index 0000000000..b962d42b14 Binary files /dev/null and b/cms/static/img/large-problem-icon.png differ diff --git a/cms/static/img/large-slide-icon.png b/cms/static/img/large-slide-icon.png new file mode 100644 index 0000000000..04241fa2f7 Binary files /dev/null and b/cms/static/img/large-slide-icon.png differ diff --git a/cms/static/img/large-textbook-icon.png b/cms/static/img/large-textbook-icon.png new file mode 100644 index 0000000000..1ac2db86d2 Binary files /dev/null and b/cms/static/img/large-textbook-icon.png differ diff --git a/cms/static/img/large-toggles.png b/cms/static/img/large-toggles.png new file mode 100644 index 0000000000..8c38a77ba0 Binary files /dev/null and b/cms/static/img/large-toggles.png differ diff --git a/cms/static/img/large-video-icon.png b/cms/static/img/large-video-icon.png new file mode 100644 index 0000000000..392851324c Binary files /dev/null and b/cms/static/img/large-video-icon.png differ diff --git a/cms/static/img/list-icon.png b/cms/static/img/list-icon.png new file mode 100644 index 0000000000..ab46179cec Binary files /dev/null and b/cms/static/img/list-icon.png differ diff --git a/cms/static/img/log-out-icon.png b/cms/static/img/log-out-icon.png new file mode 100644 index 0000000000..887d59f45d Binary files /dev/null and b/cms/static/img/log-out-icon.png differ diff --git a/cms/static/img/multi-example.png b/cms/static/img/multi-example.png new file mode 100644 index 0000000000..abe729a94b Binary files /dev/null and b/cms/static/img/multi-example.png differ diff --git a/cms/static/img/new-folder-icon.png b/cms/static/img/new-folder-icon.png new file mode 100644 index 0000000000..6bcef05c3e Binary files /dev/null and b/cms/static/img/new-folder-icon.png differ diff --git a/cms/static/img/new-unit-icon.png b/cms/static/img/new-unit-icon.png new file mode 100644 index 0000000000..ba5d706953 Binary files /dev/null and b/cms/static/img/new-unit-icon.png differ diff --git a/cms/static/img/number-example.png b/cms/static/img/number-example.png new file mode 100644 index 0000000000..7cd050cb5e Binary files /dev/null and b/cms/static/img/number-example.png differ diff --git a/cms/static/img/plus-icon-small.png b/cms/static/img/plus-icon-small.png new file mode 100644 index 0000000000..1abb12b6da Binary files /dev/null and b/cms/static/img/plus-icon-small.png differ diff --git a/cms/static/img/plus-icon-white.png b/cms/static/img/plus-icon-white.png new file mode 100644 index 0000000000..d2c5263f93 Binary files /dev/null and b/cms/static/img/plus-icon-white.png differ diff --git a/cms/static/img/plus-icon.png b/cms/static/img/plus-icon.png new file mode 100644 index 0000000000..3ffa4f2f69 Binary files /dev/null and b/cms/static/img/plus-icon.png differ diff --git a/cms/static/img/preview.jpg b/cms/static/img/preview.jpg new file mode 100644 index 0000000000..c69e60d9b0 Binary files /dev/null and b/cms/static/img/preview.jpg differ diff --git a/cms/static/img/problem-editor-icons.png b/cms/static/img/problem-editor-icons.png new file mode 100644 index 0000000000..27eb57b668 Binary files /dev/null and b/cms/static/img/problem-editor-icons.png differ diff --git a/cms/static/img/search-icon.png b/cms/static/img/search-icon.png new file mode 100644 index 0000000000..7368f803d5 Binary files /dev/null and b/cms/static/img/search-icon.png differ diff --git a/cms/static/img/select-example.png b/cms/static/img/select-example.png new file mode 100644 index 0000000000..ef80e629de Binary files /dev/null and b/cms/static/img/select-example.png differ diff --git a/cms/static/img/sequence-icon.png b/cms/static/img/sequence-icon.png new file mode 100644 index 0000000000..f95065b5eb Binary files /dev/null and b/cms/static/img/sequence-icon.png differ diff --git a/cms/static/img/slides-icon.png b/cms/static/img/slides-icon.png new file mode 100644 index 0000000000..e1dae5185b Binary files /dev/null and b/cms/static/img/slides-icon.png differ diff --git a/cms/static/img/small-home-icon.png b/cms/static/img/small-home-icon.png new file mode 100644 index 0000000000..5755bf659d Binary files /dev/null and b/cms/static/img/small-home-icon.png differ diff --git a/cms/static/img/small-toggle-icons.png b/cms/static/img/small-toggle-icons.png new file mode 100644 index 0000000000..ad6585862e Binary files /dev/null and b/cms/static/img/small-toggle-icons.png differ diff --git a/cms/static/img/small-toggle-off.png b/cms/static/img/small-toggle-off.png new file mode 100644 index 0000000000..2238454bae Binary files /dev/null and b/cms/static/img/small-toggle-off.png differ diff --git a/cms/static/img/small-toggle-on.png b/cms/static/img/small-toggle-on.png new file mode 100644 index 0000000000..f744e920c5 Binary files /dev/null and b/cms/static/img/small-toggle-on.png differ diff --git a/cms/static/img/spinner-in-field.gif b/cms/static/img/spinner-in-field.gif new file mode 100644 index 0000000000..fe2f556f5e Binary files /dev/null and b/cms/static/img/spinner-in-field.gif differ diff --git a/cms/static/img/string-example.png b/cms/static/img/string-example.png new file mode 100644 index 0000000000..6f628b20d4 Binary files /dev/null and b/cms/static/img/string-example.png differ diff --git a/cms/static/img/textbook-icon.png b/cms/static/img/textbook-icon.png new file mode 100644 index 0000000000..11e4abb363 Binary files /dev/null and b/cms/static/img/textbook-icon.png differ diff --git a/cms/static/img/upload-icon.png b/cms/static/img/upload-icon.png new file mode 100644 index 0000000000..0a78627f87 Binary files /dev/null and b/cms/static/img/upload-icon.png differ diff --git a/cms/static/img/video-icon.png b/cms/static/img/video-icon.png new file mode 100644 index 0000000000..5f8c930d16 Binary files /dev/null and b/cms/static/img/video-icon.png differ diff --git a/cms/static/img/video-module.png b/cms/static/img/video-module.png new file mode 100644 index 0000000000..2a0c340d47 Binary files /dev/null and b/cms/static/img/video-module.png differ diff --git a/cms/static/img/white-drag-handles.png b/cms/static/img/white-drag-handles.png new file mode 100644 index 0000000000..802a663893 Binary files /dev/null and b/cms/static/img/white-drag-handles.png differ diff --git a/cms/static/js/base.js b/cms/static/js/base.js new file mode 100644 index 0000000000..e99fc9a4da --- /dev/null +++ b/cms/static/js/base.js @@ -0,0 +1,854 @@ +var $body; +var $modal; +var $modalCover; +var $newComponentItem; +var $changedInput; +var $spinner; + +$(document).ready(function() { + $body = $('body'); + $modal = $('.history-modal'); + $modalCover = $(' + \ No newline at end of file diff --git a/cms/templates/edit-static-page.html b/cms/templates/edit-static-page.html new file mode 100644 index 0000000000..04740a74af --- /dev/null +++ b/cms/templates/edit-static-page.html @@ -0,0 +1,41 @@ +<%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> +<%block name="title">Edit Static Page +<%block name="bodyclass">edit-static-page + +<%block name="content"> +
            +
            +
            +
            +
            + + +
            +
            + + +
            +
            +
            + +
            +
            + \ No newline at end of file diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html new file mode 100644 index 0000000000..c6ffb14124 --- /dev/null +++ b/cms/templates/edit-tabs.html @@ -0,0 +1,46 @@ +<%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> +<%block name="title">Tabs +<%block name="bodyclass">static-pages + +<%block name="jsextra"> + + + +<%block name="content"> +
            +
            +
            +
            +

            Here you can add and manage additional pages for your course

            +

            These pages will be added to the primary navigation menu alongside Courseware, Course Info, Discussion, etc.

            +
            + + + +
            +
              + % for id in components: +
            1. + % endfor + +
            2. + +
            3. +
            +
            +
            +
            +
            + \ No newline at end of file diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html new file mode 100644 index 0000000000..d3b6f73f13 --- /dev/null +++ b/cms/templates/edit_subsection.html @@ -0,0 +1,142 @@ +<%inherit file="base.html" /> +<%! + from time import mktime + import dateutil.parser + import logging + from datetime import datetime +%> + +<%! from django.core.urlresolvers import reverse %> +<%block name="bodyclass">subsection +<%block name="title">CMS Subsection + +<%namespace name="units" file="widgets/units.html" /> +<%namespace name='static' file='static_content.html'/> +<%namespace name='datetime' module='datetime'/> + +<%block name="content"> +
            +
            +
            +
            +
            + + +
            +
            + + +
            +
            + + ${units.enum_units(subsection, subsection_units=subsection_units)} +
            +
            +
            + + + + + + + + +<%block name="jsextra"> + + + + + + + + + + diff --git a/cms/templates/editable_preview.html b/cms/templates/editable_preview.html new file mode 100644 index 0000000000..731fd9b1c8 --- /dev/null +++ b/cms/templates/editable_preview.html @@ -0,0 +1,13 @@ +
            +${content} +
            + Edit + Delete +
            + +
            +
            Edit Video Component
            + + SaveCancel +
            +
            diff --git a/cms/templates/emails/activation_email.txt b/cms/templates/emails/activation_email.txt index 209ff98335..5a1d63b670 100644 --- a/cms/templates/emails/activation_email.txt +++ b/cms/templates/emails/activation_email.txt @@ -1,4 +1,4 @@ -Thank you for signing up for edX! To activate your account, +Thank you for signing up for edX edge! To activate your account, please copy and paste this address into your web browser's address bar: diff --git a/cms/templates/emails/activation_email_subject.txt b/cms/templates/emails/activation_email_subject.txt index 495e0b5fad..0b0fb2ffe9 100644 --- a/cms/templates/emails/activation_email_subject.txt +++ b/cms/templates/emails/activation_email_subject.txt @@ -1 +1 @@ -Your account for edX +Your account for edX edge diff --git a/cms/templates/error.html b/cms/templates/error.html new file mode 100644 index 0000000000..e170b4e2c6 --- /dev/null +++ b/cms/templates/error.html @@ -0,0 +1,23 @@ +<%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> +<%block name="bodyclass">error +<%block name="title"> + % if error == '404': + 404 - Page Not Found + % elif error == '500': + 500 - Internal Server Error + % endif + + +<%block name="content"> +
            + % if error == '404': +

            Hmm…

            +

            we can't find that page.

            + % elif error == '500': +

            Oops…

            +

            there was a problem with the server.

            + % endif + Back to dashboard +
            + \ No newline at end of file diff --git a/cms/templates/export.html b/cms/templates/export.html new file mode 100644 index 0000000000..fcdd26458a --- /dev/null +++ b/cms/templates/export.html @@ -0,0 +1,54 @@ +<%inherit file="base.html" /> +<%namespace name='static' file='static_content.html'/> + +<%! from django.core.urlresolvers import reverse %> +<%block name="title">Export +<%block name="bodyclass">export + +<%block name="content"> +
            +
            +
            +
            +

            About Exporting Courses

            +

            When exporting your course, you will receive a .tar.gz formatted file that contains the following course data:

            + +
              +
            • Course Structure (Sections and sub-section ordering)
            • +
            • Individual Units
            • +
            • Individual Problems
            • +
            • Static Pages
            • +
            • Course Assets
            • +
            + +

            Your course export will not include: student data, forum/discussion data, course settings, certificates, grading information, or user data.

            +
            + + +
            +
            +

            Export Course:

            + +

            + + Download Files +
            +
            + + + <%doc> +
            +
            +

            Export Course:

            + +

            + + Files Downloading +

            Download not start? Try again

            +
            +
            + +
            +
            +
            + diff --git a/cms/templates/import.html b/cms/templates/import.html new file mode 100644 index 0000000000..e4f8019714 --- /dev/null +++ b/cms/templates/import.html @@ -0,0 +1,74 @@ +<%inherit file="base.html" /> +<%namespace name='static' file='static_content.html'/> + +<%! from django.core.urlresolvers import reverse %> +<%block name="title">Import +<%block name="bodyclass">import + +<%block name="content"> +
            +
            +
            +
            +

            Please read the documentation before attempting an import!

            +

            Importing a new course will delete all content currently associated with your course + and replace it with the contents of the uploaded file.

            +

            File uploads must be gzipped tar files (.tar.gz or .tgz) containing, at a minimum, a course.xml file.

            +

            Please note that if your course has any problems with auto-generated url_name nodes, + re-importing your course could cause the loss of student data associated with those problems.

            +
            +
            +

            Course to import:

            +

            + Choose File +

            change

            + + +
            +
            +
            0%
            +
            +
            +
            +
            +
            + + +<%block name="jsextra"> + + \ No newline at end of file diff --git a/cms/templates/index.html b/cms/templates/index.html index 6e3cb648ae..92987babda 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -2,19 +2,63 @@ <%block name="bodyclass">index <%block name="title">Courses -<%block name="content"> -

            edX Course Management

            - -
            -
            -

            Courses

            - + -
            - -
              - %for course, url in courses: -
            1. ${course}
            2. - %endfor -
            -
            +<%block name="header_extras"> + + + +<%block name="content"> +
            +
            +

            My Courses

            +
            + % if user.is_active: + New Course + + % else: +
            +

            + In order to start authoring courses using edX studio, please click on the activation link in your email. +

            +
            + % endif +
            +
            +
            diff --git a/cms/templates/login.html b/cms/templates/login.html index aa493a5c8a..580dd90309 100644 --- a/cms/templates/login.html +++ b/cms/templates/login.html @@ -1,75 +1,67 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> <%block name="title">Log in +<%block name="bodyclass">no-header <%block name="content"> -
            +
            -
            +
            - -
            + diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 3887b4cbcb..36930f5386 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -1,38 +1,115 @@ <%inherit file="base.html" /> -<%block name="title">Course Editor Manager -<%include file="widgets/header.html"/> +<%block name="title">Course Staff Manager +<%block name="bodyclass">users <%block name="content"> -
            +
            +
            +
            + %if allow_actions: + + New User + + %endif +
            -

            Course Editors

            -
              - % for user in editors: -
            • ${user.email} (${user.username})
            • - % endfor -
            +
            +

            The following list of users have been designated as course staff. This means that these users will have permissions to modify course content. You may add additional course staff below, if you are the course instructor. Please note that they must have already registered and verified their account.

            +
            -
            - - -
            -
            +
            + %if allow_actions: +
            +
            +
            + + + +
            +
            + %endif +
            +
              + % for user in staff: +
            1. + ${user.username} + ${user.email} + %if allow_actions : +
              + %if request_user_id != user.id: + + %endif +
              + %endif +
            2. + % endfor +
            +
            +
            +
            +
            + - - -
            diff --git a/cms/templates/overview.html b/cms/templates/overview.html new file mode 100644 index 0000000000..a20531200e --- /dev/null +++ b/cms/templates/overview.html @@ -0,0 +1,200 @@ +<%inherit file="base.html" /> +<%! + from time import mktime + import dateutil.parser + import logging + from datetime import datetime +%> +<%! from django.core.urlresolvers import reverse %> +<%block name="title">CMS Courseware Overview + +<%namespace name='static' file='static_content.html'/> +<%namespace name="units" file="widgets/units.html" /> + +<%block name="jsextra"> + + + + + + + + + + + +<%block name="header_extras"> + + + + + + + +<%block name="content"> +
            +
            +

            Section Release Date

            +
            + + +
            +

            On the date set above, this section – – will be released to students. Any units marked private will only be visible to admins.

            +
            +
            + SaveCancel +
            +
            + +
            +
            + +
            + % for section in sections: +
            +
            + + +
            +

            + ${section.display_name} + +

            + +
            + +
            + + +
            +
            +
            + +
              + % for subsection in section.get_children(): + + % endfor +
            +
            +
            + % endfor +
            +
            +
            +
            + diff --git a/cms/templates/registration/activation_complete.html b/cms/templates/registration/activation_complete.html index 30e731e8cc..8cc3dc8c56 100644 --- a/cms/templates/registration/activation_complete.html +++ b/cms/templates/registration/activation_complete.html @@ -3,28 +3,24 @@ <%namespace name='static' file='../static_content.html'/> +<%block name="content">
            - %if not already_active: -

            Activation Complete!

            - %else: -

            Account already active!

            - %endif -
            - -

            + +

            %if not already_active: - Thanks for activating your account. + Thanks for activating your account. %else: This account has already been activated. %endif - + %if user_logged_in: - Visit your dashboard to see your courses. + Visit your dashboard to see your courses. %else: You can now login. %endif

            + diff --git a/cms/templates/settings.html b/cms/templates/settings.html new file mode 100644 index 0000000000..1fa5b0acce --- /dev/null +++ b/cms/templates/settings.html @@ -0,0 +1,729 @@ +<%inherit file="base.html" /> +<%block name="bodyclass">settings +<%block name="title">Settings + +<%namespace name='static' file='static_content.html'/> +<%! +from contentstore import utils +%> + + +<%block name="jsextra"> + + + + + + + + + + + + + + + +<%block name="content"> + +
            +
            +

            Settings

            +
            + +
            + +
            +

            Course Details

            + +
            +
            +

            Basic Information

            + The nuts and bolts of your course +
            + +
            + +
            +
            + + This is used in your course URL, and cannot be changed +
            +
            +
            + +
            + +
            +
            + + This is used in your course URL, and cannot be changed +
            +
            +
            + +
            + +
            +
            + + This is used in your course URL, and cannot be changed +
            +
            +
            +
            + +
            + +
            +
            +

            Course Schedule

            + Important steps and segments of your course +
            + +
            +

            Course Dates:

            + +
            +
            +
            + + + First day the course begins +
            + +
            + + + +
            +
            +
            + +
            +
            +
            + + + Last day the course is active +
            + +
            + + + +
            +
            +
            +
            + +
            +

            Enrollment Dates:

            + +
            +
            +
            + + + First day students can enroll +
            + +
            + + + +
            +
            +
            + +
            +
            +
            + + + Last day students can enroll +
            + +
            + + + +
            +
            +
            +
            + + +
            + +
            + +
            +
            +

            Introducing Your Course

            + Information for perspective students +
            + +
            + +
            +
            + + Introductions, prerequisites, FAQs that are used on your course summary page +
            +
            +
            + +
            + +
            +
            +
            + + + Delete Video +
            +
            + +
            + + Video restrictions go here +
            +
            +
            +
            + +
            + +
            +
            +

            Requirements

            + Expectations of the students taking this course +
            + +
            + +
            +
            + + Time spent on all course work +
            +
            +
            +
            +
            + +
            +

            Faculty

            + +
            +
            +

            Faculty Members

            + Individuals instructing and help with this course +
            + +
            +
            +
              +
            • +
              + +
              + +
              +
              + +
              + +
              + +
              +
              + +
              + + +
              + +
              + +
              + + A brief description of your education, experience, and expertise +
              +
              + + Delete Faculty Member +
            • + +
            • +
              + +
              + +
              +
              + +
              + +
              + +
              +
              + +
              + +
              +
              + + Upload Faculty Photo + + Max size: 30KB +
              +
              +
              + +
              + +
              +
              + + A brief description of your education, experience, and expertise +
              +
              +
              +
            • +
            + + + New Faculty Member + +
            +
            +
            + +
            + +
            +

            Grading

            + +
            +
            +

            Overall Grade Range

            + Course grade ranges and their values +
            + +
            + +
            + +
            +
            +
              +
            1. 0
            2. +
            3. 10
            4. +
            5. 20
            6. +
            7. 30
            8. +
            9. 40
            10. +
            11. 50
            12. +
            13. 60
            14. +
            15. 70
            16. +
            17. 80
            18. +
            19. 90
            20. +
            21. 100
            22. +
            +
              +
            +
            +
            +
            + +
            +
            + +
            +
            +

            General Grading

            + Deadlines and Requirements +
            + +
            + + +
            +
            + + leeway on due dates +
            +
            +
            +
            + +
            +
            +

            Assignment Types

            +
            + + +
            +
            + +
            +

            Problems

            + +
            +
            +

            General Settings

            + Course-wide settings for all problems +
            + +
            +

            Problem Randomization:

            + +
            +
            + + +
            + + randomize all problems +
            +
            + +
            + + +
            + + do not randomize problems +
            +
            + +
            + + +
            + + randomize problems per student +
            +
            +
            +
            + +
            +

            Show Answers:

            + +
            +
            + + +
            + + Answers will be shown after the number of attempts has been met +
            +
            + +
            + + +
            + + Answers will never be shown, regardless of attempts +
            +
            +
            +
            + +
            + + +
            +
            + + Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0" +
            +
            +
            +
            + +
            +
            +

            [Assignment Type Name]

            +
            + +
            +

            Problem Randomization:

            + +
            +
            + + +
            + + randomize all problems +
            +
            + +
            + + +
            + + do not randomize problems +
            +
            + +
            + + +
            + + randomize problems per student +
            +
            +
            +
            + +
            +

            Show Answers:

            + +
            +
            + + +
            + + Answers will be shown after the number of attempts has been met +
            +
            + +
            + + +
            + + Answers will never be shown, regardless of attempts +
            +
            +
            +
            + +
            + + +
            +
            + + Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0" +
            +
            +
            +
            +
            + +
            +

            Discussions

            + +
            +
            +

            General Settings

            + Course-wide settings for online discussion +
            + +
            +

            Anonymous Discussions:

            + +
            +
            + + +
            + + Students and faculty will be able to post anonymously +
            +
            + +
            + + +
            + + Posting anonymously is not allowed. Any previous anonymous posts will be reverted to non-anonymous +
            +
            +
            +
            + +
            +

            Anonymous Discussions:

            + +
            +
            + + +
            + + Students and faculty will be able to post anonymously +
            +
            + +
            + + +
            + + This option is disabled since there are previous discussions that are anonymous. +
            +
            +
            +
            + +
            +

            Discussion Categories

            + +
            + + + + New Discussion Category + +
            +
            +
            +
            +
            +
            +
            +
            +
            + diff --git a/cms/templates/signup.html b/cms/templates/signup.html index f22e3c7950..2c60b758e6 100644 --- a/cms/templates/signup.html +++ b/cms/templates/signup.html @@ -1,52 +1,62 @@ <%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> + <%block name="title">Sign up +<%block name="bodyclass">no-header <%block name="content"> -
            -
            -
            -

            Sign Up for edX

            -
            -
            - -
            - -
            -
            - - - - - - - - - - - - - - - - - -
            - -
            -
            - - +
            + - -
            - -
            - + \ No newline at end of file diff --git a/cms/templates/static-pages.html b/cms/templates/static-pages.html new file mode 100644 index 0000000000..67945f0832 --- /dev/null +++ b/cms/templates/static-pages.html @@ -0,0 +1,41 @@ +<%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> +<%block name="title">Static Pages +<%block name="bodyclass">static-pages + +<%block name="content"> +
            +
            +

            Static Pages

            +
            + +
            + +
            +
            + \ No newline at end of file diff --git a/cms/templates/temp-course-landing.html b/cms/templates/temp-course-landing.html new file mode 100644 index 0000000000..4c3aab4c67 --- /dev/null +++ b/cms/templates/temp-course-landing.html @@ -0,0 +1,38 @@ +<%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> +<%block name="title">Landing +<%block name="bodyclass">no-header class-landing + +<%block name="content"> +
            +
            +
            +

            Circuits and Electronics

            +

            Massachusetts Institute of Technology

            +
            + +
            +

            Ut laoreet dolore magna aliquam erat volutpat ut wisi enim ad minim veniam quis nostrud. Est usus legentis in iis qui, facit eorum claritatem Investigationes demonstraverunt lectores. Vel illum dolore eu feugiat nulla facilisis at vero eros, et accumsan et iusto? Te feugait nulla facilisi nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming! Et quinta decima eodem modo typi qui nunc nobis, videntur parum clari fiant sollemnes in? Diam nonummy nibh euismod tincidunt exerci tation ullamcorper, suscipit lobortis nisl ut aliquip ex? Nunc putamus parum, claram anteposuerit litterarum formas humanitatis per seacula quarta decima.

            +

            Gothica quam nunc putamus parum claram anteposuerit litterarum formas humanitatis per seacula. Facilisi nam liber tempor cum soluta nobis eleifend.

            +

            +
            +
            + +
            + \ No newline at end of file diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 0db507f897..ef94d51576 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -1,61 +1,152 @@ -
            -
            -
            -

            ${url_name}

            -

            ${category}

            -
            +<%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> +<%namespace name="units" file="widgets/units.html" /> +<%block name="bodyclass">unit +<%block name="title">CMS Unit +<%block name="jsextra"> + - -
            + +<%block name="content"> +
            +
            +
            +

            You are editing a draft. + % if published_date: + This unit was originally published on ${published_date}. + % endif +

            + Preview the published version +
            +
            +
            +

            +
              + % for id in components: +
            1. + % endfor +
            2. +
              +
              Add New Component
              +
                + % for type in sorted(component_templates.keys()): +
              • + + + ${type} + +
              • + % endfor +
              +
              + % for type, templates in sorted(component_templates.items()): +
              +

              Select ${type} component type:

              -
              -
              -
              - - Settings -
              -
              -
              -
              Last modified:
              -
              mm/dd/yy
              -
              By
              -
              Anant Agarwal
              -
              -
              -
              -
              -

              Tags:

              -

              Click to edit

              + + Cancel +
              + % endfor +
            3. +
            +
            +
            + +
            -
            - ${contents} - % if lms_link is not None: - View in LMS - % endif -
            - % for preview in previews: -
            - ${preview} -
            - % endfor -
            -
            - <%include file="widgets/notes.html"/> -
            - +
            + + diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index c0b9f9e3af..5f41452339 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -1,26 +1,40 @@ <%! from django.core.urlresolvers import reverse %> -
            -