diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 2a9d48c35c..0000000000 --- a/Gemfile.lock +++ /dev/null @@ -1,15 +0,0 @@ -GEM - remote: http://rubygems.org/ - specs: - bourbon (1.3.6) - sass (>= 3.1) - rake (0.9.2.2) - sass (3.1.15) - -PLATFORMS - ruby - -DEPENDENCIES - bourbon (~> 1.3.6) - rake - sass (= 3.1.15) diff --git a/cms/djangoapps/contentstore/__init__.py b/cms/djangoapps/contentstore/__init__.py index e69de29bb2..433278c47b 100644 --- a/cms/djangoapps/contentstore/__init__.py +++ b/cms/djangoapps/contentstore/__init__.py @@ -0,0 +1,29 @@ +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.xml import XMLModuleStore +import logging + +log = logging.getLogger(__name__) + + +def import_from_xml(org, course, data_dir): + """ + Import the specified xml data_dir into the django defined modulestore, + using org and course as the location org and course. + """ + module_store = XMLModuleStore(org, course, data_dir, 'xmodule.raw_module.RawDescriptor', eager=True) + for module in module_store.modules.itervalues(): + + # TODO (cpennington): This forces import to overrite the same items. + # This should in the future create new revisions of the items on import + try: + modulestore().create_item(module.location) + except: + log.exception('Item already exists at %s' % module.location.url()) + pass + if 'data' in module.definition: + modulestore().update_item(module.location, module.definition['data']) + if 'children' in module.definition: + modulestore().update_children(module.location, module.definition['children']) + modulestore().update_metadata(module.location, dict(module.metadata)) + + return module_store.course diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index f97ac10d41..89323c3d9b 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -3,8 +3,7 @@ ### from django.core.management.base import BaseCommand, CommandError -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.xml import XMLModuleStore +from contentstore import import_from_xml unnamed_modules = 0 @@ -18,12 +17,4 @@ class Command(BaseCommand): raise CommandError("import requires 3 arguments: ") org, course, data_dir = args - - module_store = XMLModuleStore(org, course, data_dir, 'xmodule.raw_module.RawDescriptor', eager=True) - for module in module_store.modules.itervalues(): - modulestore().create_item(module.location) - if 'data' in module.definition: - modulestore().update_item(module.location, module.definition['data']) - if 'children' in module.definition: - modulestore().update_children(module.location, module.definition['children']) - modulestore().update_metadata(module.location, dict(module.metadata)) + import_from_xml(org, course, data_dir) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 76a904a403..8ac6aa610e 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -1,20 +1,36 @@ -from mitxmako.shortcuts import render_to_response -from xmodule.modulestore.django import modulestore -from django_future.csrf import ensure_csrf_cookie -from django.http import HttpResponse +from util.json_request import expect_json import json +from django.http import HttpResponse +from django_future.csrf import ensure_csrf_cookie from fs.osfs import OSFS +from django.core.urlresolvers import reverse +from xmodule.modulestore import Location +from github_sync import repo_path_from_location, export_to_github + +from mitxmako.shortcuts import render_to_response +from xmodule.modulestore.django import modulestore + @ensure_csrf_cookie def index(request): + courses = modulestore().get_items(['i4x', None, None, 'course', None]) + return render_to_response('index.html', { + 'courses': [(course.metadata['display_name'], + reverse('course_index', args=[ + course.location.org, + course.location.course, + course.location.name])) + for course in courses] + }) + + +@ensure_csrf_cookie +def course_index(request, org, course, name): # TODO (cpennington): These need to be read in from the active user - org = 'mit.edu' - course = '6002xs12' - name = '6.002_Spring_2012' course = modulestore().get_item(['i4x', org, course, 'course', name]) weeks = course.get_children() - return render_to_response('index.html', {'weeks': weeks}) + return render_to_response('course_index.html', {'weeks': weeks}) def edit_item(request): @@ -28,10 +44,19 @@ def edit_item(request): }) +@expect_json def save_item(request): item_id = request.POST['id'] data = json.loads(request.POST['data']) modulestore().update_item(item_id, data) + + # Export the course back to github + course_location = Location(item_id)._replace(category='course', name=None) + courses = modulestore().get_items(course_location) + for course in courses: + repo_path = repo_path_from_location(course.location) + export_to_github(course, repo_path, "CMS Edit") + return HttpResponse(json.dumps({})) diff --git a/cms/djangoapps/github_sync/__init__.py b/cms/djangoapps/github_sync/__init__.py new file mode 100644 index 0000000000..c901ad37e7 --- /dev/null +++ b/cms/djangoapps/github_sync/__init__.py @@ -0,0 +1,75 @@ +import logging +import os + +from django.conf import settings +from fs.osfs import OSFS +from git import Repo, PushInfo + +from contentstore import import_from_xml +from xmodule.modulestore import Location + +from .exceptions import GithubSyncError + +log = logging.getLogger(__name__) + +def import_from_github(repo_settings): + """ + Imports data into the modulestore based on the XML stored on github + + repo_settings is a dictionary with the following keys: + path: file system path to the local git repo + branch: name of the branch to track on github + org: name of the organization to use in the imported course + course: name of the coures to use in the imported course + """ + repo_path = repo_settings['path'] + + 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']) + git_repo.head.reset('origin/%s' % repo_settings['branch'], index=True, working_tree=True) + + return git_repo.head.commit.hexsha, import_from_xml(repo_settings['org'], repo_settings['course'], repo_path) + + +def repo_path_from_location(location): + location = Location(location) + for name, repo in settings.REPOS.items(): + if repo['org'] == location.org and repo['course'] == location.course: + return repo['path'] + + +def export_to_github(course, repo_path, commit_message): + fs = OSFS(repo_path) + xml = course.export_to_xml(fs) + + with fs.open('course.xml', 'w') as course_xml: + course_xml.write(xml) + + git_repo = Repo(repo_path) + if git_repo.is_dirty(): + git_repo.git.add(A=True) + 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 new file mode 100644 index 0000000000..9097ffc2a6 --- /dev/null +++ b/cms/djangoapps/github_sync/exceptions.py @@ -0,0 +1,2 @@ +class GithubSyncError(Exception): + pass diff --git a/cms/djangoapps/github_sync/tests/__init__.py b/cms/djangoapps/github_sync/tests/__init__.py new file mode 100644 index 0000000000..825fc68313 --- /dev/null +++ b/cms/djangoapps/github_sync/tests/__init__.py @@ -0,0 +1,96 @@ +from django.test import TestCase +from path import path +import shutil +from github_sync import import_from_github, export_to_github, repo_path_from_location +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 + + +class GithubSyncTestCase(TestCase): + + def setUp(self): + self.working_dir = path(settings.TEST_ROOT) + self.repo_dir = self.working_dir / 'local_repo' + self.remote_dir = self.working_dir / 'remote_repo' + shutil.copytree('common/test/data/toy', self.remote_dir) + + remote = Repo.init(self.remote_dir) + remote.git.add(A=True) + remote.git.commit(m='Initial commit') + remote.git.config("receive.denyCurrentBranch", "ignore") + + modulestore().collection.drop() + + self.import_revision, self.import_course = import_from_github({ + 'path': self.repo_dir, + 'origin': self.remote_dir, + 'branch': 'master', + 'org': 'org', + 'course': 'course' + }) + + def tearDown(self): + shutil.rmtree(self.repo_dir) + shutil.rmtree(self.remote_dir) + + 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(self.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://org/course/chapter/Overview'), + [child.location for child in self.import_course.get_children()]) + self.assertEquals(1, len(self.import_course.get_children())) + + @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, self.repo_dir, 'Test no-push') + self.assertEquals(1, Repo(self.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, self.repo_dir, 'Test push') + self.assertEquals(2, Repo(self.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(self.remote_dir) + remote.git.commit(allow_empty=True, m="Testing conflict commit") + + self.assertRaises(GithubSyncError, export_to_github, self.import_course, self.repo_dir, 'Test push') + self.assertEquals(2, remote.head.reference.commit.count()) + self.assertEquals("Testing conflict commit\n", remote.head.reference.commit.message) + + +@override_settings(REPOS={'namea': {'path': 'patha', 'org': 'orga', 'course': 'coursea'}, + 'nameb': {'path': 'pathb', 'org': 'orgb', 'course': 'courseb'}}) +class RepoPathLookupTestCase(TestCase): + def test_successful_lookup(self): + self.assertEquals('patha', repo_path_from_location('i4x://orga/coursea/course/foo')) + self.assertEquals('pathb', repo_path_from_location('i4x://orgb/courseb/course/foo')) + + def test_failed_lookup(self): + self.assertEquals(None, repo_path_from_location('i4x://c/c/course/foo')) diff --git a/cms/djangoapps/github_sync/tests/test_views.py b/cms/djangoapps/github_sync/tests/test_views.py new file mode 100644 index 0000000000..9e8095a67b --- /dev/null +++ b/cms/djangoapps/github_sync/tests/test_views.py @@ -0,0 +1,53 @@ +import json +from django.test.client import Client +from django.test import TestCase +from mock import patch, Mock +from override_settings import override_settings +from django.conf import settings + + +@override_settings(REPOS={'repo': {'path': 'path', 'branch': 'branch'}}) +class PostReceiveTestCase(TestCase): + def setUp(self): + self.client = Client() + + @patch('github_sync.views.export_to_github') + @patch('github_sync.views.import_from_github') + def test_non_branch(self, import_from_github, export_to_github): + self.client.post('/github_service_hook', {'payload': json.dumps({ + 'ref': 'refs/tags/foo'}) + }) + self.assertFalse(import_from_github.called) + self.assertFalse(export_to_github.called) + + @patch('github_sync.views.export_to_github') + @patch('github_sync.views.import_from_github') + def test_non_watched_repo(self, import_from_github, export_to_github): + self.client.post('/github_service_hook', {'payload': json.dumps({ + 'ref': 'refs/heads/branch', + 'repository': {'name': 'bad_repo'}}) + }) + self.assertFalse(import_from_github.called) + self.assertFalse(export_to_github.called) + + @patch('github_sync.views.export_to_github') + @patch('github_sync.views.import_from_github') + def test_non_tracked_branch(self, import_from_github, export_to_github): + self.client.post('/github_service_hook', {'payload': json.dumps({ + 'ref': 'refs/heads/non_branch', + 'repository': {'name': 'repo'}}) + }) + self.assertFalse(import_from_github.called) + self.assertFalse(export_to_github.called) + + @patch('github_sync.views.export_to_github') + @patch('github_sync.views.import_from_github', return_value=(Mock(), Mock())) + def test_tracked_branch(self, import_from_github, export_to_github): + self.client.post('/github_service_hook', {'payload': json.dumps({ + 'ref': 'refs/heads/branch', + 'repository': {'name': 'repo'}}) + }) + import_from_github.assert_called_with(settings.REPOS['repo']) + mock_revision, mock_course = import_from_github.return_value + export_to_github.assert_called_with(mock_course, 'path', "Changes from cms import of revision %s" % mock_revision) + diff --git a/cms/djangoapps/github_sync/views.py b/cms/djangoapps/github_sync/views.py new file mode 100644 index 0000000000..8bf654f430 --- /dev/null +++ b/cms/djangoapps/github_sync/views.py @@ -0,0 +1,52 @@ +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, export_to_github + +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 = settings.REPOS[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') + + revision, course = import_from_github(repo) + export_to_github(course, repo['path'], "Changes from cms import of revision %s" % revision) + + return HttpResponse('Push recieved') diff --git a/cms/envs/common.py b/cms/envs/common.py index 6edd6aa2b9..9782ef2fd0 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -24,12 +24,14 @@ import tempfile import os.path import os import errno +import glob2 from path import path ############################ FEATURE CONFIGURATION ############################# MITX_FEATURES = { 'USE_DJANGO_PIPELINE': True, + 'GITHUB_PUSH': False, } ############################# SET PATH INFORMATION ############################# @@ -57,6 +59,10 @@ MAKO_TEMPLATES['main'] = [ COMMON_ROOT / 'djangoapps' / 'pipeline_mako' / 'templates' ] +TEMPLATE_DIRS = ( + PROJECT_ROOT / "templates", +) + MITX_ROOT_URL = '' TEMPLATE_CONTEXT_PROCESSORS = ( @@ -67,6 +73,9 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.core.context_processors.csrf', # necessary for csrf protection ) +################################# Jasmine ################################### +JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' + ################################# Middleware ################################### # List of finder classes that know how to find static files in # various locations. @@ -183,12 +192,16 @@ for xmodule in XModuleDescriptor.load_classes() + [RawDescriptor]: PIPELINE_JS = { 'main': { - 'source_filenames': ['coffee/main.coffee', 'coffee/unit.coffee'], - 'output_filename': 'js/main.js', + 'source_filenames': [pth.replace(PROJECT_ROOT / 'static/', '') for pth in glob2.glob(PROJECT_ROOT / 'static/coffee/src/**/*.coffee')], + 'output_filename': 'js/application.js', }, 'module-js': { 'source_filenames': module_js_sources, 'output_filename': 'js/modules.js', + }, + 'spec': { + 'source_filenames': [pth.replace(PROJECT_ROOT / 'static/', '') for pth in glob2.glob(PROJECT_ROOT / 'static/coffee/spec/**/*.coffee')], + 'output_filename': 'js/spec.js' } } @@ -232,4 +245,7 @@ INSTALLED_APPS = ( # For asset pipelining 'pipeline', 'staticfiles', + + # For testing + 'django_jasmine', ) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index b4bcbfa9ce..ca48481184 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -29,8 +29,48 @@ DATABASES = { } } +REPO_ROOT = ENV_ROOT / "content" + +REPOS = { + 'edx4edx': { + 'path': REPO_ROOT / "edx4edx", + 'org': 'edx', + 'course': 'edx4edx', + 'branch': 'for_cms', + 'origin': 'git@github.com:MITx/edx4edx.git', + }, + '6002x-fall-2012': { + 'path': REPO_ROOT / '6002x-fall-2012', + 'org': 'mit.edu', + 'course': '6.002x', + 'branch': 'for_cms', + 'origin': 'git@github.com:MITx/6002x-fall-2012.git', + }, + '6.00x': { + 'path': REPO_ROOT / '6.00x', + 'org': 'mit.edu', + 'course': '6.00x', + 'branch': 'for_cms', + 'origin': 'git@github.com:MITx/6.00x.git', + }, + '7.00x': { + 'path': REPO_ROOT / '7.00x', + 'org': 'mit.edu', + 'course': '7.00x', + 'branch': 'for_cms', + 'origin': 'git@github.com:MITx/7.00x.git', + }, + '3.091x': { + 'path': REPO_ROOT / '3.091x', + 'org': 'mit.edu', + 'course': '3.091x', + 'branch': 'for_cms', + 'origin': 'git@github.com:MITx/3.091x.git', + }, +} + CACHES = { - # This is the cache used for most things. Askbot will not work without a + # 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. # In staging/prod envs, the sessions also live here. 'default': { diff --git a/cms/envs/test.py b/cms/envs/test.py index 032de92953..927e2af987 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -17,10 +17,18 @@ for app in os.listdir(PROJECT_ROOT / 'djangoapps'): NOSE_ARGS += ['--cover-package', app] TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' +TEST_ROOT = 'test_root' + MODULESTORE = { - 'host': 'localhost', - 'db': 'mongo_base', - 'collection': 'key_store', + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore', + } + } } DATABASES = { diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json new file mode 100644 index 0000000000..b396bec944 --- /dev/null +++ b/cms/static/coffee/files.json @@ -0,0 +1,8 @@ +{ + "js_files": [ + "/static/js/vendor/jquery.min.js", + "/static/js/vendor/json2.js", + "/static/js/vendor/underscore-min.js", + "/static/js/vendor/backbone-min.js" + ] +} diff --git a/cms/static/coffee/main.coffee b/cms/static/coffee/main.coffee deleted file mode 100644 index 8f7d7d7323..0000000000 --- a/cms/static/coffee/main.coffee +++ /dev/null @@ -1,90 +0,0 @@ -class @CMS - @setHeight = => - windowHeight = $(this).height() - @contentHeight = windowHeight - 29 - - @bind = => - $('a.module-edit').click -> - CMS.edit_item($(this).attr('id')) - return false - $(window).bind('resize', CMS.setHeight) - - @edit_item = (id) => - $.get('/edit_item', {id: id}, (data) => - $('#module-html').empty().append(data) - CMS.bind() - $('body.content .cal').css('height', @contentHeight) - $('body').addClass('content') - $('section.edit-pane').show() - new Unit('unit-wrapper', id) - ) - -$ -> - $.ajaxSetup - headers : { 'X-CSRFToken': $.cookie 'csrftoken' } - $('section.main-content').children().hide() - $('.editable').inlineEdit() - $('.editable-textarea').inlineEdit({control: 'textarea'}) - - heighest = 0 - $('.cal ol > li').each -> - heighest = if $(this).height() > heighest then $(this).height() else heighest - - $('.cal ol > li').css('height',heighest + 'px') - - $('.add-new-section').click -> return false - - $('.new-week .close').click -> - $(this).parents('.new-week').hide() - $('p.add-new-week').show() - return false - - $('.save-update').click -> - $(this).parent().parent().hide() - return false - - # $('html').keypress -> - # $('.wip').css('visibility', 'visible') - - setHeight = -> - windowHeight = $(this).height() - contentHeight = windowHeight - 29 - - $('section.main-content > section').css('min-height', contentHeight) - $('body.content .cal').css('height', contentHeight) - - $('.edit-week').click -> - $('body').addClass('content') - $('body.content .cal').css('height', contentHeight) - $('section.edit-pane').show() - return false - - $('a.week-edit').click -> - $('body').addClass('content') - $('body.content .cal').css('height', contentHeight) - $('section.edit-pane').show() - return false - - $('a.sequence-edit').click -> - $('body').addClass('content') - $('body.content .cal').css('height', contentHeight) - $('section.edit-pane').show() - return false - - $('a.module-edit').click -> - $('body.content .cal').css('height', contentHeight) - - $(document).ready(setHeight) - $(window).bind('resize', setHeight) - - $('.video-new a').click -> - $('section.edit-pane').show() - return false - - $('.problem-new a').click -> - $('section.edit-pane').show() - return false - - CMS.setHeight() - CMS.bind() - diff --git a/cms/static/coffee/spec/helpers.coffee b/cms/static/coffee/spec/helpers.coffee new file mode 100644 index 0000000000..91a411a8fc --- /dev/null +++ b/cms/static/coffee/spec/helpers.coffee @@ -0,0 +1,18 @@ +# Stub jQuery.cookie +@stubCookies = + csrftoken: "stubCSRFToken" + +jQuery.cookie = (key, value) => + if value? + @stubCookies[key] = value + else + @stubCookies[key] + +# Path Jasmine's `it` method to raise an error when the test is not defined. +# This is helpful when writing the specs first before writing the test. +@it = (desc, func) -> + if func? + jasmine.getEnv().it(desc, func) + else + jasmine.getEnv().it desc, -> + throw "test is undefined" diff --git a/cms/static/coffee/spec/main_spec.coffee b/cms/static/coffee/spec/main_spec.coffee new file mode 100644 index 0000000000..c8f6976fed --- /dev/null +++ b/cms/static/coffee/spec/main_spec.coffee @@ -0,0 +1,90 @@ +describe "CMS", -> + beforeEach -> + CMS.unbind() + + it "should iniitalize Models", -> + expect(CMS.Models).toBeDefined() + + 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) + window.stubCookies["csrftoken"] = "stubCSRFToken" + $(document).ready() + + afterEach -> + $.ajaxSettings = @previousAjaxSettings + + it "turn on Backbone emulateHTTP", -> + expect(Backbone.emulateHTTP).toBeTruthy() + + it "setup AJAX CSRF token", -> + expect($.ajaxSettings.headers["X-CSRFToken"]).toEqual("stubCSRFToken") diff --git a/cms/static/coffee/spec/models/module_spec.coffee b/cms/static/coffee/spec/models/module_spec.coffee new file mode 100644 index 0000000000..43ebdf420a --- /dev/null +++ b/cms/static/coffee/spec/models/module_spec.coffee @@ -0,0 +1,65 @@ +describe "CMS.Models.Module", -> + it "set the correct URL", -> + 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") + @stubElement = $("
") + @module.loadModule(@stubElement) + + afterEach -> + window.FakeModule = undefined + + it "initialize the module", -> + expect(window.FakeModule).toHaveBeenCalledWith(@stubElement) + 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).toHaveBeenCalledWith("Unable to load HTML.") + + + 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() diff --git a/cms/static/coffee/spec/views/course_spec.coffee b/cms/static/coffee/spec/views/course_spec.coffee new file mode 100644 index 0000000000..f6a430ac2d --- /dev/null +++ b/cms/static/coffee/spec/views/course_spec.coffee @@ -0,0 +1,85 @@ +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 new file mode 100644 index 0000000000..693353ff70 --- /dev/null +++ b/cms/static/coffee/spec/views/module_edit_spec.coffee @@ -0,0 +1,81 @@ +describe "CMS.Views.ModuleEdit", -> + beforeEach -> + @stubModule = jasmine.createSpyObj("Module", ["editUrl", "loadModule"]) + spyOn($.fn, "load") + setFixtures """ +
+ save + cancel +
    +
  1. + submodule +
  2. +
+
+ """ + CMS.unbind() + + describe "defaults", -> + it "set the correct tagName", -> + expect(new CMS.Views.ModuleEdit(model: @stubModule).tagName).toEqual("section") + + it "set the correct className", -> + expect(new CMS.Views.ModuleEdit(model: @stubModule).className).toEqual("edit-pane") + + describe "view creation", -> + beforeEach -> + @stubModule.editUrl.andReturn("/edit_item?id=stub_module") + new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule) + + it "load the edit from 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)) + + describe "save", -> + beforeEach -> + @stubJqXHR = jasmine.createSpy("stubJqXHR") + @stubJqXHR.success = jasmine.createSpy("stubJqXHR.success").andReturn(@stubJqXHR) + @stubJqXHR.error= jasmine.createSpy("stubJqXHR.success").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() + + it "call save on the model", -> + expect(@stubModule.save).toHaveBeenCalled() + + it "alert user on success", -> + @stubJqXHR.success.mostRecentCall.args[0]() + expect(window.alert).toHaveBeenCalledWith("Your changes have been saved.") + + 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.edu/course/module" + type: "html" diff --git a/cms/static/coffee/spec/views/module_spec.coffee b/cms/static/coffee/spec/views/module_spec.coffee new file mode 100644 index 0000000000..a42c06856c --- /dev/null +++ b/cms/static/coffee/spec/views/module_spec.coffee @@ -0,0 +1,24 @@ +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.edu/course/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 new file mode 100644 index 0000000000..754474d77f --- /dev/null +++ b/cms/static/coffee/spec/views/week_edit_spec.coffee @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..74b8c22fde --- /dev/null +++ b/cms/static/coffee/spec/views/week_spec.coffee @@ -0,0 +1,67 @@ +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 new file mode 100644 index 0000000000..b88bc7210b --- /dev/null +++ b/cms/static/coffee/src/main.coffee @@ -0,0 +1,35 @@ +@CMS = + Models: {} + Views: {} + + 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 + +$ -> + Backbone.emulateHTTP = true + + $.ajaxSetup + headers : { 'X-CSRFToken': $.cookie 'csrftoken' } + + CMS.start($('section.main-container')) diff --git a/cms/static/coffee/src/models/module.coffee b/cms/static/coffee/src/models/module.coffee new file mode 100644 index 0000000000..257eca411e --- /dev/null +++ b/cms/static/coffee/src/models/module.coffee @@ -0,0 +1,17 @@ +class CMS.Models.Module extends Backbone.Model + url: '/save_item' + defaults: + data: '' + + loadModule: (element) -> + try + @module = new window[@get('type')](element) + catch TypeError + console.error "Unable to load #{@get('type')}." if console + + editUrl: -> + "/edit_item?#{$.param(id: @get('id'))}" + + save: (args...) -> + @set(data: JSON.stringify(@module.save())) if @module + super(args...) diff --git a/cms/static/coffee/src/views/course.coffee b/cms/static/coffee/src/views/course.coffee new file mode 100644 index 0000000000..2a5a012c07 --- /dev/null +++ b/cms/static/coffee/src/views/course.coffee @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000000..5407204706 --- /dev/null +++ b/cms/static/coffee/src/views/module.coffee @@ -0,0 +1,7 @@ +class CMS.Views.Module extends Backbone.View + events: + "click .module-edit": "edit" + + edit: (event) => + event.preventDefault() + CMS.replaceView(new CMS.Views.ModuleEdit(model: new CMS.Models.Module(id: @$el.data('id'), type: @$el.data('type')))) diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee new file mode 100644 index 0000000000..16968a9126 --- /dev/null +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -0,0 +1,28 @@ +class CMS.Views.ModuleEdit extends Backbone.View + tagName: 'section' + className: 'edit-pane' + + events: + 'click .cancel': 'cancel' + 'click .module-edit': 'editSubmodule' + 'click .save-update': 'save' + + initialize: -> + @$el.load @model.editUrl(), => + @model.loadModule(@el) + + save: (event) -> + event.preventDefault() + @model.save().success(-> + alert("Your changes have been saved.") + ).error(-> + alert("There was an error saving your changes. Please try again.") + ) + + cancel: (event) -> + event.preventDefault() + CMS.popView() + + editSubmodule: (event) -> + event.preventDefault() + CMS.pushView(new CMS.Views.ModuleEdit(model: new CMS.Models.Module(id: $(event.target).data('id'), type: $(event.target).data('type')))) diff --git a/cms/static/coffee/src/views/week.coffee b/cms/static/coffee/src/views/week.coffee new file mode 100644 index 0000000000..8483b9d134 --- /dev/null +++ b/cms/static/coffee/src/views/week.coffee @@ -0,0 +1,25 @@ +class CMS.Views.Week extends Backbone.View + events: + 'click .week-edit': 'edit' + + 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('') diff --git a/cms/static/coffee/src/views/week_edit.coffee b/cms/static/coffee/src/views/week_edit.coffee new file mode 100644 index 0000000000..3082bc9fe2 --- /dev/null +++ b/cms/static/coffee/src/views/week_edit.coffee @@ -0,0 +1,3 @@ +class CMS.Views.WeekEdit extends Backbone.View + tagName: 'section' + className: 'edit-pane' diff --git a/cms/static/coffee/unit.coffee b/cms/static/coffee/unit.coffee index b81bc0df08..dda41fe42b 100644 --- a/cms/static/coffee/unit.coffee +++ b/cms/static/coffee/unit.coffee @@ -4,7 +4,7 @@ class @Unit $("##{@element_id} .save-update").click (event) => event.preventDefault() - $.post("save_item", { + $.post("/save_item", { id: @module_id data: JSON.stringify(@module.save()) }) diff --git a/cms/static/img/content-types/chapter.png b/cms/static/img/content-types/chapter.png new file mode 100644 index 0000000000..2903f668d0 Binary files /dev/null and b/cms/static/img/content-types/chapter.png differ diff --git a/cms/static/img/content-types/html.png b/cms/static/img/content-types/html.png new file mode 100644 index 0000000000..566d0a232e Binary files /dev/null and b/cms/static/img/content-types/html.png differ diff --git a/cms/static/img/content-types/lab.png b/cms/static/img/content-types/lab.png new file mode 100644 index 0000000000..f5b457b8f2 Binary files /dev/null and b/cms/static/img/content-types/lab.png differ diff --git a/cms/static/img/content-types/problem.png b/cms/static/img/content-types/problem.png new file mode 100644 index 0000000000..198e57d629 Binary files /dev/null and b/cms/static/img/content-types/problem.png differ diff --git a/cms/static/img/content-types/problemset.png b/cms/static/img/content-types/problemset.png new file mode 100644 index 0000000000..85576db2ac Binary files /dev/null and b/cms/static/img/content-types/problemset.png differ diff --git a/cms/static/img/content-types/sequential.png b/cms/static/img/content-types/sequential.png new file mode 100644 index 0000000000..f0fda6d490 Binary files /dev/null and b/cms/static/img/content-types/sequential.png differ diff --git a/cms/static/img/content-types/vertical.png b/cms/static/img/content-types/vertical.png new file mode 100644 index 0000000000..17e4833f8b Binary files /dev/null and b/cms/static/img/content-types/vertical.png differ diff --git a/cms/static/img/content-types/video.png b/cms/static/img/content-types/video.png new file mode 100644 index 0000000000..1c9f0176bc Binary files /dev/null and b/cms/static/img/content-types/video.png differ diff --git a/cms/static/img/content-types/videosequence.png b/cms/static/img/content-types/videosequence.png new file mode 100644 index 0000000000..3a6c85843b Binary files /dev/null and b/cms/static/img/content-types/videosequence.png differ diff --git a/cms/static/js/vendor/backbone-min.js b/cms/static/js/vendor/backbone-min.js new file mode 100644 index 0000000000..c1c0d4fff2 --- /dev/null +++ b/cms/static/js/vendor/backbone-min.js @@ -0,0 +1,38 @@ +// Backbone.js 0.9.2 + +// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc. +// Backbone may be freely distributed under the MIT license. +// For all details and documentation: +// http://backbonejs.org +(function(){var l=this,y=l.Backbone,z=Array.prototype.slice,A=Array.prototype.splice,g;g="undefined"!==typeof exports?exports:l.Backbone={};g.VERSION="0.9.2";var f=l._;!f&&"undefined"!==typeof require&&(f=require("underscore"));var i=l.jQuery||l.Zepto||l.ender;g.setDomLibrary=function(a){i=a};g.noConflict=function(){l.Backbone=y;return this};g.emulateHTTP=!1;g.emulateJSON=!1;var p=/\s+/,k=g.Events={on:function(a,b,c){var d,e,f,g,j;if(!b)return this;a=a.split(p);for(d=this._callbacks||(this._callbacks= +{});e=a.shift();)f=(j=d[e])?j.tail:{},f.next=g={},f.context=c,f.callback=b,d[e]={tail:g,next:j?j.next:f};return this},off:function(a,b,c){var d,e,h,g,j,q;if(e=this._callbacks){if(!a&&!b&&!c)return delete this._callbacks,this;for(a=a?a.split(p):f.keys(e);d=a.shift();)if(h=e[d],delete e[d],h&&(b||c))for(g=h.tail;(h=h.next)!==g;)if(j=h.callback,q=h.context,b&&j!==b||c&&q!==c)this.on(d,j,q);return this}},trigger:function(a){var b,c,d,e,f,g;if(!(d=this._callbacks))return this;f=d.all;a=a.split(p);for(g= +z.call(arguments,1);b=a.shift();){if(c=d[b])for(e=c.tail;(c=c.next)!==e;)c.callback.apply(c.context||this,g);if(c=f){e=c.tail;for(b=[b].concat(g);(c=c.next)!==e;)c.callback.apply(c.context||this,b)}}return this}};k.bind=k.on;k.unbind=k.off;var o=g.Model=function(a,b){var c;a||(a={});b&&b.parse&&(a=this.parse(a));if(c=n(this,"defaults"))a=f.extend({},c,a);b&&b.collection&&(this.collection=b.collection);this.attributes={};this._escapedAttributes={};this.cid=f.uniqueId("c");this.changed={};this._silent= +{};this._pending={};this.set(a,{silent:!0});this.changed={};this._silent={};this._pending={};this._previousAttributes=f.clone(this.attributes);this.initialize.apply(this,arguments)};f.extend(o.prototype,k,{changed:null,_silent:null,_pending:null,idAttribute:"id",initialize:function(){},toJSON:function(){return f.clone(this.attributes)},get:function(a){return this.attributes[a]},escape:function(a){var b;if(b=this._escapedAttributes[a])return b;b=this.get(a);return this._escapedAttributes[a]=f.escape(null== +b?"":""+b)},has:function(a){return null!=this.get(a)},set:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c||(c={});if(!d)return this;d instanceof o&&(d=d.attributes);if(c.unset)for(e in d)d[e]=void 0;if(!this._validate(d,c))return!1;this.idAttribute in d&&(this.id=d[this.idAttribute]);var b=c.changes={},h=this.attributes,g=this._escapedAttributes,j=this._previousAttributes||{};for(e in d){a=d[e];if(!f.isEqual(h[e],a)||c.unset&&f.has(h,e))delete g[e],(c.silent?this._silent: +b)[e]=!0;c.unset?delete h[e]:h[e]=a;!f.isEqual(j[e],a)||f.has(h,e)!=f.has(j,e)?(this.changed[e]=a,c.silent||(this._pending[e]=!0)):(delete this.changed[e],delete this._pending[e])}c.silent||this.change(c);return this},unset:function(a,b){(b||(b={})).unset=!0;return this.set(a,null,b)},clear:function(a){(a||(a={})).unset=!0;return this.set(f.clone(this.attributes),a)},fetch:function(a){var a=a?f.clone(a):{},b=this,c=a.success;a.success=function(d,e,f){if(!b.set(b.parse(d,f),a))return!1;c&&c(b,d)}; +a.error=g.wrapError(a.error,b,a);return(this.sync||g.sync).call(this,"read",this,a)},save:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c=c?f.clone(c):{};if(c.wait){if(!this._validate(d,c))return!1;e=f.clone(this.attributes)}a=f.extend({},c,{silent:!0});if(d&&!this.set(d,c.wait?a:c))return!1;var h=this,i=c.success;c.success=function(a,b,e){b=h.parse(a,e);if(c.wait){delete c.wait;b=f.extend(d||{},b)}if(!h.set(b,c))return false;i?i(h,a):h.trigger("sync",h,a,c)};c.error=g.wrapError(c.error, +h,c);b=this.isNew()?"create":"update";b=(this.sync||g.sync).call(this,b,this,c);c.wait&&this.set(e,a);return b},destroy:function(a){var a=a?f.clone(a):{},b=this,c=a.success,d=function(){b.trigger("destroy",b,b.collection,a)};if(this.isNew())return d(),!1;a.success=function(e){a.wait&&d();c?c(b,e):b.trigger("sync",b,e,a)};a.error=g.wrapError(a.error,b,a);var e=(this.sync||g.sync).call(this,"delete",this,a);a.wait||d();return e},url:function(){var a=n(this,"urlRoot")||n(this.collection,"url")||t(); +return this.isNew()?a:a+("/"==a.charAt(a.length-1)?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this.attributes)},isNew:function(){return null==this.id},change:function(a){a||(a={});var b=this._changing;this._changing=!0;for(var c in this._silent)this._pending[c]=!0;var d=f.extend({},a.changes,this._silent);this._silent={};for(c in d)this.trigger("change:"+c,this,this.get(c),a);if(b)return this;for(;!f.isEmpty(this._pending);){this._pending= +{};this.trigger("change",this,a);for(c in this.changed)!this._pending[c]&&!this._silent[c]&&delete this.changed[c];this._previousAttributes=f.clone(this.attributes)}this._changing=!1;return this},hasChanged:function(a){return!arguments.length?!f.isEmpty(this.changed):f.has(this.changed,a)},changedAttributes:function(a){if(!a)return this.hasChanged()?f.clone(this.changed):!1;var b,c=!1,d=this._previousAttributes,e;for(e in a)if(!f.isEqual(d[e],b=a[e]))(c||(c={}))[e]=b;return c},previous:function(a){return!arguments.length|| +!this._previousAttributes?null:this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},isValid:function(){return!this.validate(this.attributes)},_validate:function(a,b){if(b.silent||!this.validate)return!0;var a=f.extend({},this.attributes,a),c=this.validate(a,b);if(!c)return!0;b&&b.error?b.error(this,c,b):this.trigger("error",this,c,b);return!1}});var r=g.Collection=function(a,b){b||(b={});b.model&&(this.model=b.model);b.comparator&&(this.comparator=b.comparator); +this._reset();this.initialize.apply(this,arguments);a&&this.reset(a,{silent:!0,parse:b.parse})};f.extend(r.prototype,k,{model:o,initialize:function(){},toJSON:function(a){return this.map(function(b){return b.toJSON(a)})},add:function(a,b){var c,d,e,g,i,j={},k={},l=[];b||(b={});a=f.isArray(a)?a.slice():[a];c=0;for(d=a.length;c=b))this.iframe=i('' % (settings.MITX_ROOT_URL,pfn) + msg += '' % (settings.MITX_ROOT_URL,pfn) msg += '
' endmsg = """

Note: if the code text box disappears after clicking on "Check", diff --git a/lms/lib/dogfood/views.py b/lms/lib/dogfood/views.py index ba8601cc20..d621603f31 100644 --- a/lms/lib/dogfood/views.py +++ b/lms/lib/dogfood/views.py @@ -25,7 +25,7 @@ import track.views from lxml import etree -from courseware.module_render import make_track_function, I4xSystem +from courseware.module_render import make_track_function, I4xSystem, get_module from courseware.models import StudentModule from multicourse import multicourse_settings from student.models import UserProfile @@ -55,6 +55,7 @@ def update_problem(pfn,pxml,coursename=None,overwrite=True,filestore=None): else: pfn2 = 'problems/%s.xml' % pfn fp = filestore.open(pfn2,'w') + log.debug('[dogfood.update_problem] pfn2=%s' % pfn2) if os.path.exists(pfn2) and not overwrite: return # don't overwrite if already exists and overwrite=False pxmls = pxml if type(pxml) in [str,unicode] else etree.tostring(pxml,pretty_print=True) @@ -71,7 +72,7 @@ def df_capa_problem(request, id=None): # "WARNING: UNDEPLOYABLE CODE. FOR DEV USE ONLY." if settings.DEBUG: - print '[lib.dogfood.df_capa_problem] id=%s' % id + log.debug('[lib.dogfood.df_capa_problem] id=%s' % id) if not 'coursename' in request.session: coursename = DOGFOOD_COURSENAME @@ -86,7 +87,7 @@ def df_capa_problem(request, id=None): try: xml = content_parser.module_xml(request.user, module, 'id', id, coursename) except Exception,err: - print "[lib.dogfood.df_capa_problem] error in calling content_parser: %s" % err + log.error("[lib.dogfood.df_capa_problem] error in calling content_parser: %s" % err) xml = None # if problem of given ID does not exist, then create it @@ -96,7 +97,7 @@ def df_capa_problem(request, id=None): if not m: raise Exception,'[lib.dogfood.df_capa_problem] Illegal problem id %s' % id pfn = m.group(1) - print '[lib.dogfood.df_capa_problem] creating new problem pfn=%s' % pfn + log.debug('[lib.dogfood.df_capa_problem] creating new problem pfn=%s' % pfn) # add problem to course.xml fn = settings.DATA_DIR + xp + 'course.xml' @@ -126,7 +127,7 @@ def df_capa_problem(request, id=None): 'groups' : groups} filename = xp + 'course.xml' cache_key = filename + "_processed?dev_content:" + str(options['dev_content']) + "&groups:" + str(sorted(groups)) - print '[lib.dogfood.df_capa_problem] cache_key = %s' % cache_key + log.debug('[lib.dogfood.df_capa_problem] cache_key = %s' % cache_key) #cache.delete(cache_key) tree = content_parser.course_xml_process(xml) # add ID tags cache.set(cache_key,etree.tostring(tree),60) @@ -134,7 +135,7 @@ def df_capa_problem(request, id=None): xml = content_parser.module_xml(request.user, module, 'id', id, coursename) if not xml: - print "[lib.dogfood.df_capa_problem] problem xml not found!" + log.debug("[lib.dogfood.df_capa_problem] problem xml not found!") # add problem ID to list so that is_staff check can be bypassed request.session['dogfood_id'] = id @@ -170,6 +171,31 @@ def quickedit(request, id=None, qetemplate='quickedit.html',coursename=None): xp = multicourse_settings.get_course_xmlpath(coursename) # path to XML for the course def get_lcp(coursename,id): + # Grab the XML corresponding to the request from course.xml + # create empty student state for this problem, if not previously existing + s = StudentModule.objects.filter(student=request.user, + module_id=id) + student_module_cache = list(s) if s is not None else [] + #if len(s) == 0 or s is None: + # smod=StudentModule(student=request.user, + # module_type = 'problem', + # module_id=id, + # state=instance.get_state()) + # smod.save() + # student_module_cache = [smod] + module = 'problem' + module_xml = etree.XML(content_parser.module_xml(request.user, module, 'id', id, coursename)) + module_id = module_xml.get('id') + log.debug("module_id = %s" % module_id) + (instance,smod,module_type) = get_module(request.user, request, module_xml, student_module_cache, position=None) + log.debug('[dogfood.views] instance=%s' % instance) + lcp = instance.lcp + log.debug('[dogfood.views] lcp=%s' % lcp) + pxml = lcp.tree + pxmls = etree.tostring(pxml,pretty_print=True) + return instance, pxmls + + def old_get_lcp(coursename,id): # Grab the XML corresponding to the request from course.xml module = 'problem' xml = content_parser.module_xml(request.user, module, 'id', id, coursename) @@ -280,7 +306,8 @@ def quickedit_git_reload(request): if 'gitupdate' in request.POST: import os # FIXME - put at top? - cmd = "cd ../data%s; git reset --hard HEAD; git pull origin %s" % (xp,xp.replace('/','')) + #cmd = "cd ../data%s; git reset --hard HEAD; git pull origin %s" % (xp,xp.replace('/','')) + cmd = "cd ../data%s; ./GITRELOAD '%s'" % (xp,xp.replace('/','')) msg += '

cmd: %s

' % cmd ret = os.popen(cmd).read() msg += '

%s

' % ret.replace('<','<') diff --git a/lms/lib/loncapa/loncapa_check.py b/lms/lib/loncapa/loncapa_check.py index 259c7909ac..961e24ea70 100644 --- a/lms/lib/loncapa/loncapa_check.py +++ b/lms/lib/loncapa/loncapa_check.py @@ -5,7 +5,9 @@ # Python functions which duplicate the standard comparison functions available to LON-CAPA problems. # Used in translating LON-CAPA problems to i4x problem specification language. +from __future__ import division import random +import math def lc_random(lower,upper,stepsize): ''' @@ -15,3 +17,20 @@ def lc_random(lower,upper,stepsize): choices = [lower+x*stepsize for x in range(nstep)] return random.choice(choices) +def lc_choose(index,*args): + ''' + return args[index] + ''' + try: + return args[int(index)-1] + except Exception,err: + pass + if len(args): + return args[0] + raise Exception,"loncapa_check.lc_choose error, index=%s, args=%s" % (index,args) + +deg2rad = math.pi/180.0 +rad2deg = 180.0/math.pi + + + diff --git a/lms/lib/symmath/symmath_check.py b/lms/lib/symmath/symmath_check.py index 325108f3e9..5ea428b1d5 100644 --- a/lms/lib/symmath/symmath_check.py +++ b/lms/lib/symmath/symmath_check.py @@ -231,6 +231,9 @@ def symmath_check(expect,ans,dynamath=None,options=None,debug=None): dm = my_evalf(sympy.Matrix(fexpect)-sympy.Matrix(xgiven),chop=True) if abs(dm.vec().norm().evalf())quickedit - -% endif +##% if settings.QUICKEDIT: +##

quickedit

+## +##% endif diff --git a/lms/templates/courseware-error.html b/lms/templates/courseware-error.html index 35865e58e1..fe8fc9d451 100644 --- a/lms/templates/courseware-error.html +++ b/lms/templates/courseware-error.html @@ -1,12 +1,12 @@ <%inherit file="main.html" /> <%block name="bodyclass">courseware -<%block name="title">Courseware – MITx 6.002x +<%block name="title">Courseware – edX <%include file="course_navigation.html" args="active_page='courseware'" />
-

There has been an error on the MITx servers

+

There has been an error on the edX servers

We're sorry, this module is temporarily unavailable. Our staff is working to fix it as soon as possible. Please email us at technical@mitx.mit.edu to report any problems or downtime.

diff --git a/lms/templates/create_account.html b/lms/templates/create_account.html index 318a694658..a9427c11dc 100644 --- a/lms/templates/create_account.html +++ b/lms/templates/create_account.html @@ -1,12 +1,12 @@
-

Enroll in 6.002x Circuits & Electronics

+

Enroll in edx4edx

-Please note that 6.002x has now passed its half-way point. The midterm exam and several assignment due dates for 6.002x have already passed. It is now impossible for newly enrolled students to earn a passing grade and a completion certificate for the course. However, new students have access to all of the course material that has been released for the course, so you are welcome to enroll and browse the course.

+

<% if 'error' in locals(): e = error %> @@ -35,7 +35,7 @@ Please note that 6.002x has now passed its half-way point. The midterm exam and -
If you successfully complete the course, you will receive an electronic certificate of accomplishment from MITx with this name on it.
+
If you successfully complete the course, you will receive an electronic certificate of accomplishment from edX with this name on it.
  • diff --git a/lms/templates/emails/activation_email.txt b/lms/templates/emails/activation_email.txt index 7f042995ed..eca5effdd6 100644 --- a/lms/templates/emails/activation_email.txt +++ b/lms/templates/emails/activation_email.txt @@ -1,14 +1,14 @@ -Someone, hopefully you, signed up for an account for MITx's on-line -offering of 6.002 using this email address. If it was you, and you'd -like to activate and use your account, copy and paste this address -into your web browser's address bar: +Someone, hopefully you, signed up for an account for edX's on-line +offering of "${ course_title}" using this email address. If it was +you, and you'd like to activate and use your account, copy and paste +this address into your web browser's address bar: % if is_secure: https://${ site }/activate/${ key } % else: - http://${ site }/activate/${ key } + http://edx4edx.mitx.mit.edu/activate/${ key } % endif If you didn't request this, you don't need to do anything; you won't receive any more email from us. Please do not reply to this e-mail; if -you require assistance, check the help section of the MITx web site. +you require assistance, check the help section of the edX web site. diff --git a/lms/templates/emails/activation_email_subject.txt b/lms/templates/emails/activation_email_subject.txt index 00231fa8b1..c25c006a81 100644 --- a/lms/templates/emails/activation_email_subject.txt +++ b/lms/templates/emails/activation_email_subject.txt @@ -1 +1 @@ -Your account for MITx's on-line 6.002 +Your account for edX's on-line ${course_title} course diff --git a/lms/templates/honor.html b/lms/templates/honor.html index a763eb4f50..cfdefb5465 100644 --- a/lms/templates/honor.html +++ b/lms/templates/honor.html @@ -4,24 +4,24 @@

    Collaboration Policy

    -

    By enrolling in a course on MITx, you are joining a +

    By enrolling in a course on edX, you are joining a special worldwide community of learners. The aspiration - of MITx is to provide anyone in the world who has the - motivation and ability to engage MIT coursework the opportunity - to attain the best MIT-based educational experience that + of edX is to provide anyone in the world who has the + motivation and ability to engage edX coursework the opportunity + to attain the best edX-based educational experience that Internet technology enables. You are part of the community who - will help MITx achieve this goal. + will help edX achieve this goal. -

    MITx depends upon your motivation to learn the material +

    edX depends upon your motivation to learn the material and to do so with honesty. In order to participate - in MITx, you must agree to the Honor Code below and any + in edX, you must agree to the Honor Code below and any additional terms specific to a class. This Honor Code, and any additional terms, will be posted on each class website.

    -

    MITx Honor Code Pledge

    +

    edX Honor Code Pledge

    -

    By enrolling in an MITx course, I agree that I will: +

    By enrolling in an edX course, I agree that I will:

    • Complete all mid-terms and final exams with my own work @@ -35,14 +35,14 @@ assess student performance.
    -

    Unless otherwise indicated by the instructor of an MITx - course, learners on MITx are encouraged to: +

    Unless otherwise indicated by the instructor of an edX + course, learners on edX are encouraged to:

    • Collaborate with others on the lecture videos, exercises, homework and labs.
    • Discuss with others general concepts and materials in each course. -
    • Present ideas and written work to fellow MITx +
    • Present ideas and written work to fellow edX learners or others for comment or criticism.
    diff --git a/lms/templates/login.html b/lms/templates/login.html index f371aa2e4c..60586ed41c 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -1,11 +1,11 @@
    -

    Log in to MITx

    +

    Log in to edX

    diff --git a/lms/templates/main.html b/lms/templates/main.html index 674caf6298..ef9c78b302 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -64,7 +64,7 @@ <%include file="navigation.html" /> ${self.body()} diff --git a/lms/templates/profile.html b/lms/templates/profile.html index 62cd3a8deb..6caaea7b5b 100644 --- a/lms/templates/profile.html +++ b/lms/templates/profile.html @@ -2,7 +2,7 @@ <%namespace name='static' file='static_content.html'/> <%namespace name="profile_graphs" file="profile_graphs.js"/> -<%block name="title">Profile - MITx 6.002x +<%block name="title">Profile - edX 6.002x <%! from django.core.urlresolvers import reverse @@ -234,10 +234,10 @@ $(function() {
    -

    To uphold the credibility of MITx certificates, name changes must go through an approval process. A member of the course staff will review your request, and if approved, update your information. Please allow up to a week for your request to be processed. Thank you.

    +

    To uphold the credibility of edX certificates, name changes must go through an approval process. A member of the course staff will review your request, and if approved, update your information. Please allow up to a week for your request to be processed. Thank you.

    • - +
    • @@ -278,7 +278,7 @@ $(function() {
    -

    Deactivate MITx Account

    +

    Deactivate edX Account

    Once you deactivate you’re MITx account you will no longer recieve updates and new class announcements from MITx.

    If you’d like to still get updates and new class announcements you can just unenroll and keep your account active.

    @@ -287,7 +287,7 @@ $(function() {
    • - +
    @@ -296,14 +296,14 @@ $(function() {

    Unenroll from 6.002x

    -

    Please note: you will still receive updates and new class announcements from MITx. If you don’t wish to receive any more updates or announcements deactivate your account.

    +

    Please note: you will still receive updates and new class announcements from edX. If you don’t wish to receive any more updates or announcements deactivate your account.

    • - +
    diff --git a/lms/templates/quickedit.html b/lms/templates/quickedit.html index c85a78d10b..bc8e74eb65 100644 --- a/lms/templates/quickedit.html +++ b/lms/templates/quickedit.html @@ -61,7 +61,7 @@ <%include file="mathjax_include.html" /> - +