From e524f5be35cefb2dd265de822a3c6eec65f09358 Mon Sep 17 00:00:00 2001 From: ichuang Date: Fri, 16 Aug 2013 16:23:47 -0400 Subject: [PATCH 1/3] add push_to_lms to Studio; uses MITX_FEATURES['ENABLE_PUSH_TO_LMS'] --- cms/djangoapps/contentstore/views/__init__.py | 1 + .../contentstore/views/push_to_lms.py | 39 ++++++++++ cms/templates/push_to_lms.html | 56 +++++++++++++ cms/templates/widgets/header.html | 5 ++ cms/urls.py | 12 ++- scripts/cms_export_to_github | 78 +++++++++++++++++++ scripts/cms_export_to_github_local | 7 ++ 7 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 cms/djangoapps/contentstore/views/push_to_lms.py create mode 100644 cms/templates/push_to_lms.html create mode 100755 scripts/cms_export_to_github create mode 100755 scripts/cms_export_to_github_local diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py index 6211169bb258..7458a883c8a1 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -14,6 +14,7 @@ from .import_export import * from .preview import * from .public import * +from .push_to_lms import * from .user import * from .tabs import * from .transcripts_ajax import * diff --git a/cms/djangoapps/contentstore/views/push_to_lms.py b/cms/djangoapps/contentstore/views/push_to_lms.py new file mode 100644 index 000000000000..2a53cceddc13 --- /dev/null +++ b/cms/djangoapps/contentstore/views/push_to_lms.py @@ -0,0 +1,39 @@ +import os +import logging + +from django.conf import settings +from django_future.csrf import ensure_csrf_cookie +from django.contrib.auth.decorators import login_required + +from mitxmako.shortcuts import render_to_response +from xmodule.modulestore.django import modulestore + +from .access import get_location_and_verify_access + +log = logging.getLogger(__name__) + +@ensure_csrf_cookie +@login_required +def push_to_lms(request, org, course, name): + """ + This method serves up the 'Push to LMS' page + """ + location = get_location_and_verify_access(request, org, course, name) + + course_module = modulestore().get_item(location) + + log.debug('push_to_lms course_module=%s' % course_module) + + msg = "" + + if 'action' in request.GET and course_module.lms.giturl: + # do the push, using script + doexport = getattr(settings, 'CMS_EXPORT_COURSE_SCRIPT', '') + if doexport and os.path.exists(doexport): + cmd = '{0} {1} {2} {3}'.format(doexport, course_module.id, request.user, course_module.lms.giturl) + msg = os.popen(cmd).read() + + return render_to_response('push_to_lms.html', { + 'context_course': course_module, + 'msg': msg, + }) diff --git a/cms/templates/push_to_lms.html b/cms/templates/push_to_lms.html new file mode 100644 index 000000000000..25c2c5d7744c --- /dev/null +++ b/cms/templates/push_to_lms.html @@ -0,0 +1,56 @@ +<%! from django.utils.translation import ugettext as _ %> +<%inherit file="base.html" /> +<%namespace name='static' file='static_content.html'/> + +<%! from django.core.urlresolvers import reverse %> +<%block name="title">${_("Push Course to LMS")} +<%block name="bodyclass">is-signedin course tools export + +<%block name="content"> +
+
+

+ ${_("Tools")} + > ${_("Push to LMS")} +

+
+
+ +
+
+ +
+
+

${_("About Push to LMS")}

+

${_("Use this to export your course to its github site.")}

+

${_("This will then trigger an automatic update of the main LMS site")} + ${_("and update the contents of your course visible there to students.")}

+ +
    +
  • ${_("Your course:")} ${context_course.id}
  • +
  • ${_("Course github site:")} ${context_course.lms.giturl}
  • + % if msg: +
  • msg:
    ${msg|n}
  • + % endif +
+ +
+ + +
+
+

${_("Push Course:")}

+ + % if not context_course.lms.giturl: +

${_("giturl must be defined in your course settings before you can push to LMS")}

+ % else: + ${_("Push to LMS")} + % endif + +
+
+ +
+
+
+ diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index ac9067e6be9e..201bd599a6c0 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -104,6 +104,11 @@

${_("Tools")} ${_("Export")} + % if settings.MITX_FEATURES.get('ENABLE_PUSH_TO_LMS') and 'allow_push_to_lms' in context_course.advanced_modules: + + % endif diff --git a/cms/urls.py b/cms/urls.py index d21f0a22227b..2360eadfb5b8 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -96,8 +96,16 @@ url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict), ) -if settings.FEATURES.get('ENABLE_SERVICE_STATUS'): - urlpatterns += patterns('', + +if settings.FEATURES.get('ENABLE_PUSH_TO_LMS'): + urlpatterns += (url(r'^(?P[^/]+)/(?P[^/]+)/push/(?P[^/]+)$', + 'contentstore.views.push_to_lms', name='push_to_lms'),) + +if settings.ENABLE_JASMINE: + urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),) + +if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): + urlpatterns += ( url(r'^status/', include('service_status.urls')), ) diff --git a/scripts/cms_export_to_github b/scripts/cms_export_to_github new file mode 100755 index 000000000000..ac9eb973376b --- /dev/null +++ b/scripts/cms_export_to_github @@ -0,0 +1,78 @@ +#!/usr/bin/python +# +# python script to export course from CMS to github +# +# Usage: python cms_export_to_github [] +# + +import os, sys, string, re +import datetime + +DIR = "/mnt/data_export" + +#----------------------------------------------------------------------------- +# usage + +def usage(): + print "Usage: python cms_export_to_github []" + +#----------------------------------------------------------------------------- + +try: + course_loc = sys.argv.pop(1) + username = sys.argv.pop(1) + repo = sys.argv.pop(1) +except: + usage() + sys.exit(0) + +if course_loc.startswith('i4x://'): + course_loc = course_loc[6:] + +m = re.match('git@[^ ]+\.git', repo) +if not m: + print "Oops, not a git ssh url?" + print repo + print "Expecting something like git@github.com:mitocw/edx4edx_lite.git" + sys.exit(-1) + +if len(sys.argv)>1: + rdir = sys.argv.pop(1) + rdir = os.path.basename(rdir) +else: + rdir = repo.rsplit('/',1)[-1].rsplit('.git',1)[0] +print "rdir = %s" % rdir + +rdirp = '%s/%s' % (DIR, rdir) +if os.path.exists(rdirp): + print "directory already exists, doing a git pull instead of git clone" + cmd = 'cd %s/%s; git pull' % (DIR, rdir) +else: + cmd = 'cd %s; git clone "%s"' % (DIR, repo) + +print cmd +ret_git = os.popen(cmd).read() +print ret_git + +if not os.path.exists('%s/%s' % (DIR, rdir)): + print "git clone failed!" + sys.exit(-1) + +#----------------------------------------------------------------------------- +# export course + +cmd = "./DJANGO-ADMIN-CMS export %s %s" % (course_loc, rdirp) +print cmd +ret_export = os.popen(cmd).read() +print ret_export + +#----------------------------------------------------------------------------- +# push to github + +dt = datetime.datetime.now() +cmd = 'cd %s; git add .; git commit -a -m "(%s) Export %s"; git push' % (rdirp, username, dt) +print cmd +ret_push = os.popen(cmd).read() +print ret_push + + diff --git a/scripts/cms_export_to_github_local b/scripts/cms_export_to_github_local new file mode 100755 index 000000000000..6cb066b0cbb2 --- /dev/null +++ b/scripts/cms_export_to_github_local @@ -0,0 +1,7 @@ +#!/bin/bash + +cd ~/mitx_all +source STARTUP +cd edx-platform + +python scripts/cms_export_to_github $* From f02c074db9ee02af7a21344963f278a5c8d96470 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Fri, 13 Dec 2013 22:55:59 -0500 Subject: [PATCH 2/3] Push To LMS updates Added tests Converted scripts to django commands Removed advanced module requirement Generalized to platform Switched to subprocess for shell commands Beefed up resiliency and error checking. Refactored since #1910 removed get_location_and_verify_access Added settings to aws for export directory and reworked test setup and teardown Several review based fixes Added line to Changelog Changed URL handler to be accepting and moved git bare repo inside of test_root/data Added exception logging to help trace issues Added output in exception logging Made the branch to commit to explicit instead of implicit Skipping git identity test on condition of global configuration set --- CHANGELOG.rst | 3 + .../management/commands/git_export.py | 221 ++++++++++++++++++ .../commands/tests/test_git_export.py | 182 +++++++++++++++ .../contentstore/tests/test_push_to_lms.py | 94 ++++++++ .../contentstore/views/push_to_lms.py | 47 ++-- cms/envs/aws.py | 3 + cms/envs/test.py | 4 + cms/static/sass/style-app-extend1.scss | 1 + cms/static/sass/views/_push.scss | 93 ++++++++ cms/templates/push_to_lms.html | 80 ++++--- cms/templates/widgets/header.html | 10 +- cms/urls.py | 9 +- scripts/cms_export_to_github | 78 ------- scripts/cms_export_to_github_local | 7 - 14 files changed, 686 insertions(+), 146 deletions(-) create mode 100644 cms/djangoapps/contentstore/management/commands/git_export.py create mode 100644 cms/djangoapps/contentstore/management/commands/tests/test_git_export.py create mode 100644 cms/djangoapps/contentstore/tests/test_push_to_lms.py create mode 100644 cms/static/sass/views/_push.scss delete mode 100755 scripts/cms_export_to_github delete mode 100755 scripts/cms_export_to_github_local diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e6f823516b70..c958366b7d36 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +CMS: Add feature to allow exporting a course to a git repository by +specifying the giturl in the course settings. + Studo: Fix import/export bug with conditional modules. STUD-149 Blades: Persist student progress in video. BLD-385. diff --git a/cms/djangoapps/contentstore/management/commands/git_export.py b/cms/djangoapps/contentstore/management/commands/git_export.py new file mode 100644 index 000000000000..47ac1d74273c --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/git_export.py @@ -0,0 +1,221 @@ +""" +This command exports a course from CMS to a git repository. +It takes as arguments the course id to export (i.e MITx/999/2020 ) and +the repository to commit too. It takes username as an option for identifying +the commit, as well as a directory path to place the git repository. + +By default it will use settings.GIT_REPO_EXPORT_DIR/repo_name as the cloned +directory. It is branch aware, but will reset all local changes to the +repository before attempting to export the XML, add, and commit changes if +any have taken place. + +This functionality is also available as an export view in studio if the giturl +attribute is set and the FEATURE['ENABLE_PUSH_TO_LMS'] is set. +""" + +import logging +from optparse import make_option +import os +import subprocess +from urlparse import urlparse + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.utils.translation import ugettext as _ + +from xmodule.contentstore.django import contentstore +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.xml_exporter import export_to_xml + +log = logging.getLogger(__name__) + +GIT_REPO_EXPORT_DIR = getattr(settings, 'GIT_REPO_EXPORT_DIR', + '/edx/var/edxapp/export_course_repos') +GIT_EXPORT_DEFAULT_IDENT = getattr(settings, 'GIT_EXPORT_DEFAULT_IDENT', + {'name': 'STUDIO_PUSH_TO_LMS', + 'email': 'STUDIO_PUSH_TO_LMS@example.com'}) + + +class GitExportError(Exception): + """ + Convenience exception class for git export error conditions. + """ + + NO_EXPORT_DIR = _("Path {0} doesn't exist, please create it, " + "or configure a different path with " + "GIT_REPO_EXPORT_DIR").format(GIT_REPO_EXPORT_DIR) + URL_BAD = _('Non writable git url provided. Expecting something like:' + ' git@github.com:mitocw/edx4edx_lite.git') + URL_NO_AUTH = _('If using http urls, you must provide the username ' + 'and password in the url. Similar to ' + 'https://user:pass@github.com/user/course.') + DETACHED_HEAD = _('Unable to determine branch, repo in detached HEAD mode') + CANNOT_PULL = _('Unable to update or clone git repository.') + XML_EXPORT_FAIL = _('Unable to export course to xml.') + CANNOT_COMMIT = _('Unable to commit or push changes.') + BAD_COURSE = _('Bad course location provided') + MISSING_BRANCH = _('Missing branch on fresh clone') + + +def cmd_log(cmd, cwd): + """ + Helper function to redirect stderr to stdout and log the command + used along with the output. Will raise subprocess.CalledProcessError if + command doesn't return 0, and returns the command's output. + """ + output = subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT) + log.debug(_('Command was: {0!r}. ' + 'Working directory was: {1!r}').format(' '.join(cmd), cwd)) + log.debug(_('Command output was: {0!r}'.format(output))) + return output + + +def export_to_git(course_loc, repo, user='', rdir=None): + """Export a course to git.""" + # pylint: disable=R0915 + + if course_loc.startswith('i4x://'): + course_loc = course_loc[6:] + + if not os.path.isdir(GIT_REPO_EXPORT_DIR): + raise GitExportError(GitExportError.NO_EXPORT_DIR) + + # Check for valid writable git url + if not (repo.endswith('.git') or + repo.startswith(('http:', 'https:', 'file:'))): + raise GitExportError(GitExportError.URL_BAD) + + # Check for username and password if using http[s] + if repo.startswith('http:') or repo.startswith('https:'): + parsed = urlparse(repo) + if parsed.username is None or parsed.password is None: + raise GitExportError(GitExportError.URL_NO_AUTH) + if rdir: + rdir = os.path.basename(rdir) + else: + rdir = repo.rsplit('/', 1)[-1].rsplit('.git', 1)[0] + + log.debug("rdir = %s", rdir) + + # Pull or clone repo before exporting to xml + # and update url in case origin changed. + rdirp = '{0}/{1}'.format(GIT_REPO_EXPORT_DIR, rdir) + branch = None + if os.path.exists(rdirp): + log.info(_('Directory already exists, doing a git reset and pull ' + 'instead of git clone.')) + cwd = rdirp + # Get current branch + cmd = ['git', 'symbolic-ref', '--short', 'HEAD', ] + try: + branch = cmd_log(cmd, cwd).strip('\n') + except subprocess.CalledProcessError as ex: + log.exception('Failed to get branch: %r', ex.output) + raise GitExportError(GitExportError.DETACHED_HEAD) + + cmds = [ + ['git', 'remote', 'set-url', 'origin', repo, ], + ['git', 'fetch', 'origin', ], + ['git', 'reset', '--hard', 'origin/{0}'.format(branch), ], + ['git', 'pull', ], + ] + else: + cmds = [['git', 'clone', repo, ], ] + cwd = GIT_REPO_EXPORT_DIR + + cwd = os.path.abspath(cwd) + for cmd in cmds: + try: + cmd_log(cmd, cwd) + except subprocess.CalledProcessError as ex: + log.exception('Failed to pull git repository: %r', ex.output) + raise GitExportError(GitExportError.CANNOT_PULL) + + # export course as xml before commiting and pushing + try: + location = CourseDescriptor.id_to_location(course_loc) + except ValueError: + raise GitExportError(GitExportError.BAD_COURSE) + + root_dir = os.path.dirname(rdirp) + course_dir = os.path.splitext(os.path.basename(rdirp))[0] + try: + export_to_xml(modulestore('direct'), contentstore(), location, + root_dir, course_dir, modulestore()) + except (EnvironmentError, AttributeError): + log.exception('Failed export to xml') + raise GitExportError(GitExportError.XML_EXPORT_FAIL) + + # Get current branch if not already set + if not branch: + cmd = ['git', 'symbolic-ref', '--short', 'HEAD', ] + try: + branch = cmd_log(cmd, os.path.abspath(rdirp)).strip('\n') + except subprocess.CalledProcessError as ex: + log.exception('Failed to get branch from freshly cloned repo: %r', + ex.output) + raise GitExportError(GitExportError.MISSING_BRANCH) + + # Now that we have fresh xml exported, set identity, add + # everything to git, commit, and push to the right branch. + ident = {} + try: + user = User.objects.get(username=user) + ident['name'] = user.username + ident['email'] = user.email + except User.DoesNotExist: + # That's ok, just use default ident + ident = GIT_EXPORT_DEFAULT_IDENT + time_stamp = timezone.now() + cwd = os.path.abspath(rdirp) + commit_msg = 'Export from Studio at {1}'.format(user, time_stamp) + try: + cmd_log(['git', 'config', 'user.email', ident['email'], ], cwd) + cmd_log(['git', 'config', 'user.name', ident['name'], ], cwd) + cmd_log(['git', 'add', '.'], cwd) + cmd_log(['git', 'commit', '-a', '-m', commit_msg], cwd) + cmd_log(['git', 'push', '-q', 'origin', branch], cwd) + except subprocess.CalledProcessError as ex: + log.exception('Error running git push commands: %r', ex.output) + raise GitExportError(GitExportError.CANNOT_COMMIT) + + +class Command(BaseCommand): + """ + Take a course from studio and export it to a git repository. + """ + + option_list = BaseCommand.option_list + ( + make_option('--user', '-u', dest='user', + help='Add a user to the commit message.'), + make_option('--repo_dir', '-r', dest='repo', + help='Specify existing git repo directory.'), + ) + + help = _('Take the specified course and attempt to ' + 'export it to a git repository\n. Course directory ' + 'must already be a git repository. Usage: ' + ' git_export ') + + def handle(self, *args, **options): + """ + Checks arguments and runs export function if they are good + """ + + if len(args) != 2: + raise CommandError(_('This script requires exactly two arguments: ' + 'course_loc and git_url')) + + # Rethrow GitExportError as CommandError for SystemExit + try: + export_to_git( + args[0], + args[1], + options.get('user', ''), + options.get('rdir', None) + ) + except GitExportError as ex: + raise CommandError(str(ex)) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py b/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py new file mode 100644 index 000000000000..6d62dcddfc32 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py @@ -0,0 +1,182 @@ +""" +Unittests for exporting to git via management command. +""" + +import copy +import os +import shutil +import StringIO +import subprocess +import unittest +from uuid import uuid4 + +from django.conf import settings +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test.utils import override_settings + +from contentstore.tests.utils import CourseTestCase +import contentstore.management.commands.git_export as git_export +from contentstore.management.commands.git_export import GitExportError + +FEATURES_WITH_PUSH_TO_LMS = settings.FEATURES.copy() +FEATURES_WITH_PUSH_TO_LMS['ENABLE_PUSH_TO_LMS'] = True +TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) +TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex + + +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) +@override_settings(FEATURES=FEATURES_WITH_PUSH_TO_LMS) +class TestGitExport(CourseTestCase): + """ + Excercise the git_export django management command with various inputs. + """ + + def setUp(self): + """ + Create/reinitialize bare repo and folders needed + """ + super(TestGitExport, self).setUp() + + if not os.path.isdir(git_export.GIT_REPO_EXPORT_DIR): + os.mkdir(git_export.GIT_REPO_EXPORT_DIR) + self.addCleanup(shutil.rmtree, git_export.GIT_REPO_EXPORT_DIR) + + self.bare_repo_dir = '{0}/data/test_bare.git'.format( + os.path.abspath(settings.TEST_ROOT)) + if not os.path.isdir(self.bare_repo_dir): + os.mkdir(self.bare_repo_dir) + self.addCleanup(shutil.rmtree, self.bare_repo_dir) + subprocess.check_output(['git', '--bare', 'init', ], + cwd=self.bare_repo_dir) + + def test_command(self): + """ + Test that the command interface works. Ignore stderr fo clean + test output. + """ + with self.assertRaises(SystemExit) as ex: + self.assertRaisesRegexp( + CommandError, 'This script requires.*', + call_command('git_export', 'blah', 'blah', 'blah', + stderr=StringIO.StringIO())) + self.assertEqual(ex.exception.code, 1) + + with self.assertRaises(SystemExit) as ex: + self.assertRaisesRegexp(CommandError, 'This script requires.*', + call_command('git_export', + stderr=StringIO.StringIO())) + self.assertEqual(ex.exception.code, 1) + + # Send bad url to get course not exported + with self.assertRaises(SystemExit) as ex: + self.assertRaisesRegexp(CommandError, GitExportError.URL_BAD, + call_command('git_export', 'foo', 'silly', + stderr=StringIO.StringIO())) + self.assertEqual(ex.exception.code, 1) + + def test_bad_git_url(self): + """ + Test several bad URLs for validation + """ + with self.assertRaisesRegexp(GitExportError, GitExportError.URL_BAD): + git_export.export_to_git('', 'Sillyness') + + with self.assertRaisesRegexp(GitExportError, GitExportError.URL_BAD): + git_export.export_to_git('', 'example.com:edx/notreal') + + with self.assertRaisesRegexp(GitExportError, + GitExportError.URL_NO_AUTH): + git_export.export_to_git('', 'http://blah') + + def test_bad_git_repos(self): + """ + Test invalid git repos + """ + test_repo_path = '{}/test_repo'.format(git_export.GIT_REPO_EXPORT_DIR) + self.assertFalse(os.path.isdir(test_repo_path)) + # Test bad clones + with self.assertRaisesRegexp(GitExportError, + GitExportError.CANNOT_PULL): + git_export.export_to_git( + 'foo/blah/100', + 'https://user:blah@example.com/test_repo.git') + self.assertFalse(os.path.isdir(test_repo_path)) + + # Setup good repo with bad course to test xml export + with self.assertRaisesRegexp(GitExportError, + GitExportError.XML_EXPORT_FAIL): + git_export.export_to_git( + 'foo/blah/100', + 'file://{0}'.format(self.bare_repo_dir)) + + # Test bad git remote after successful clone + with self.assertRaisesRegexp(GitExportError, + GitExportError.CANNOT_PULL): + git_export.export_to_git( + 'foo/blah/100', + 'https://user:blah@example.com/r.git') + + def test_bad_course_id(self): + """ + Test valid git url, but bad course. + """ + with self.assertRaisesRegexp(GitExportError, GitExportError.BAD_COURSE): + git_export.export_to_git( + '', 'file://{0}'.format(self.bare_repo_dir), '', '/blah') + + @unittest.skipIf(os.environ.get('GIT_CONFIG') or + os.environ.get('GIT_AUTHOR_EMAIL') or + os.environ.get('GIT_AUTHOR_NAME') or + os.environ.get('GIT_COMMITTER_EMAIL') or + os.environ.get('GIT_COMMITTER_NAME'), + 'Global git override set') + def test_git_ident(self): + """ + Test valid course with and without user specified. + + Test skipped if git global config override environment variable GIT_CONFIG + is set. + """ + git_export.export_to_git( + self.course.id, + 'file://{0}'.format(self.bare_repo_dir), + 'enigma' + ) + expect_string = '{0}|{1}\n'.format( + git_export.GIT_EXPORT_DEFAULT_IDENT['name'], + git_export.GIT_EXPORT_DEFAULT_IDENT['email'] + ) + cwd = os.path.abspath(git_export.GIT_REPO_EXPORT_DIR / 'test_bare') + git_log = subprocess.check_output(['git', 'log', '-1', + '--format=%an|%ae', ], cwd=cwd) + self.assertEqual(expect_string, git_log) + + # Make changes to course so there is something commit + self.populateCourse() + git_export.export_to_git( + self.course.id, + 'file://{0}'.format(self.bare_repo_dir), + self.user.username + ) + expect_string = '{0}|{1}\n'.format( + self.user.username, + self.user.email, + ) + git_log = subprocess.check_output( + ['git', 'log', '-1', '--format=%an|%ae', ], cwd=cwd) + self.assertEqual(expect_string, git_log) + + def test_no_change(self): + """ + Test response if there are no changes + """ + git_export.export_to_git( + 'i4x://{0}'.format(self.course.id), + 'file://{0}'.format(self.bare_repo_dir) + ) + + with self.assertRaisesRegexp(GitExportError, + GitExportError.CANNOT_COMMIT): + git_export.export_to_git( + self.course.id, 'file://{0}'.format(self.bare_repo_dir)) diff --git a/cms/djangoapps/contentstore/tests/test_push_to_lms.py b/cms/djangoapps/contentstore/tests/test_push_to_lms.py new file mode 100644 index 000000000000..fc7c04c5ce6d --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_push_to_lms.py @@ -0,0 +1,94 @@ +""" +Test the ability to export courses to xml from studio +""" + +import copy +import os +import shutil +import subprocess +from uuid import uuid4 + +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test.utils import override_settings +from django.utils.translation import ugettext as _ + +from .utils import CourseTestCase +import contentstore.management.commands.git_export as git_export +from xmodule.modulestore.django import modulestore + +TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) +TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex + + +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) +class TestPushToLMS(CourseTestCase): + """ + Tests pushing a course to a git repository + """ + + def setUp(self): + """ + Setup test course, user, and url. + """ + super(TestPushToLMS, self).setUp() + self.course_module = modulestore().get_item(self.course.location) + self.test_url = reverse('push_to_lms', kwargs={ + 'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + }) + + def test_giturl_missing(self): + """ + Test to make sure an appropriate error is displayed + if course hasn't set giturl. + """ + response = self.client.get(self.test_url) + self.assertEqual(200, response.status_code) + self.assertIn( + _('giturl must be defined in your ' + 'course settings before you can push to LMS.'), + response.content + ) + + response = self.client.get('{}?action=push'.format(self.test_url)) + self.assertEqual(200, response.status_code) + self.assertIn( + _('giturl must be defined in your ' + 'course settings before you can push to LMS.'), + response.content + ) + + def test_course_import_failures(self): + """ + Test failed course export response. + """ + self.course_module.giturl = 'foobar' + modulestore().save_xmodule(self.course_module) + + response = self.client.get('{}?action=push'.format(self.test_url)) + self.assertIn(_('Export Failed:'), response.content) + + def test_course_import_success(self): + """ + Test successful course export response. + """ + # Build out local bare repo, and set course git url to it + repo_dir = os.path.abspath(git_export.GIT_REPO_EXPORT_DIR) + os.mkdir(repo_dir) + self.addCleanup(shutil.rmtree, repo_dir) + + bare_repo_dir = '{0}/test_repo.git'.format( + os.path.abspath(git_export.GIT_REPO_EXPORT_DIR)) + os.mkdir(bare_repo_dir) + self.addCleanup(shutil.rmtree, bare_repo_dir) + + subprocess.check_output(['git', '--bare', 'init', ], cwd=bare_repo_dir) + + self.populateCourse() + self.course_module.giturl = 'file://{}'.format(bare_repo_dir) + modulestore().save_xmodule(self.course_module) + + response = self.client.get('{}?action=push'.format(self.test_url)) + self.assertIn(_('Export Succeeded'), response.content) diff --git a/cms/djangoapps/contentstore/views/push_to_lms.py b/cms/djangoapps/contentstore/views/push_to_lms.py index 2a53cceddc13..509414527508 100644 --- a/cms/djangoapps/contentstore/views/push_to_lms.py +++ b/cms/djangoapps/contentstore/views/push_to_lms.py @@ -1,39 +1,54 @@ -import os +""" +This views handles exporting the course xml to a git repository if +the giturl attribute is set. +""" + import logging -from django.conf import settings -from django_future.csrf import ensure_csrf_cookie from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django_future.csrf import ensure_csrf_cookie +from django.utils.translation import ugettext as _ -from mitxmako.shortcuts import render_to_response +from .access import has_access +from edxmako.shortcuts import render_to_response +from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore - -from .access import get_location_and_verify_access +import contentstore.management.commands.git_export as git_export log = logging.getLogger(__name__) + @ensure_csrf_cookie @login_required def push_to_lms(request, org, course, name): """ This method serves up the 'Push to LMS' page """ - location = get_location_and_verify_access(request, org, course, name) - + location = Location('i4x', org, course, 'course', name) + if not has_access(request.user, location): + raise PermissionDenied() course_module = modulestore().get_item(location) + failed = False - log.debug('push_to_lms course_module=%s' % course_module) + log.debug('push_to_lms course_module=%s', course_module) msg = "" - - if 'action' in request.GET and course_module.lms.giturl: - # do the push, using script - doexport = getattr(settings, 'CMS_EXPORT_COURSE_SCRIPT', '') - if doexport and os.path.exists(doexport): - cmd = '{0} {1} {2} {3}'.format(doexport, course_module.id, request.user, course_module.lms.giturl) - msg = os.popen(cmd).read() + if 'action' in request.GET and course_module.giturl: + if request.GET['action'] == 'push': + try: + git_export.export_to_git( + course_module.id, + course_module.giturl, + request.user, + ) + msg = _('Course successfully exported to git repository') + except git_export.GitExportError as ex: + failed = True + msg = str(ex) return render_to_response('push_to_lms.html', { 'context_course': course_module, 'msg': msg, + 'failed': failed, }) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index a7cd24663c3f..2c4a43a77253 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -156,6 +156,9 @@ #Timezone overrides TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) +# Push to LMS overrides +GIT_REPO_EXPORT_DIR = ENV_TOKENS.get('GIT_REPO_EXPORT_DIR', '/edx/var/edxapp/export_course_repos') + # Translation overrides LANGUAGES = ENV_TOKENS.get('LANGUAGES', LANGUAGES) LANGUAGE_CODE = ENV_TOKENS.get('LANGUAGE_CODE', LANGUAGE_CODE) diff --git a/cms/envs/test.py b/cms/envs/test.py index 617d0fa083e8..6aca0f7ccb7e 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -37,6 +37,10 @@ GITHUB_REPO_ROOT = TEST_ROOT / "data" COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data" +# For testing "push to lms" +FEATURES['ENABLE_PUSH_TO_LMS'] = True +GIT_REPO_EXPORT_DIR = TEST_ROOT / "export_course_repos" + # Makes the tests run much faster... SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead diff --git a/cms/static/sass/style-app-extend1.scss b/cms/static/sass/style-app-extend1.scss index c3997c9427e0..3cb62450376e 100644 --- a/cms/static/sass/style-app-extend1.scss +++ b/cms/static/sass/style-app-extend1.scss @@ -44,6 +44,7 @@ @import 'views/users'; @import 'views/checklists'; @import 'views/textbooks'; +@import 'views/push'; // base - contexts @import 'contexts/ie'; // ie-specific rules (mostly for known/older bugs) diff --git a/cms/static/sass/views/_push.scss b/cms/static/sass/views/_push.scss new file mode 100644 index 000000000000..743d53a586ff --- /dev/null +++ b/cms/static/sass/views/_push.scss @@ -0,0 +1,93 @@ +// studio - views - push to lms +// ==================== + +.view-push { + + // UI: basic layout + .content-primary, .content-supplementary { + @include box-sizing(border-box); + float: left; + } + + .content-primary { + width: flex-grid(9,12); + margin-right: flex-gutter(); + } + + .content-supplementary { + width: flex-grid(3,12); + } + + .error-text { + color: $error-red; + } + + h3 { + font-size: 19px; + font-weight: 700; + } + + .push-info-block { + + dt { + font-size: 19px; + font-weight: 700; + margin-top: 12px; + } + + dd { + font-size: 17px; + margin-bottom: 20px; + } + + .course_text { + color: $green; + } + .giturl_text { + color: $blue; + } + } + + // UI: introduction + .introduction { + + .title { + @extend %cont-text-sr; + } + } + + // UI: export controls + .push-controls { + @include box-sizing(border-box); + @extend %ui-window; + padding: $baseline ($baseline*1.5) ($baseline*1.5) ($baseline*1.5); + + .title { + @extend %t-title4; + } + + .action-push { + @extend %btn-primary-blue; + @extend %t-action1; + display: block; + margin: $baseline 0; + padding: ($baseline*0.75) $baseline; + } + + .action { + + [class^="icon"] { + @extend %t-icon2; + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + + .copy { + display: inline-block; + vertical-align: middle; + } + } + } +} + diff --git a/cms/templates/push_to_lms.html b/cms/templates/push_to_lms.html index 25c2c5d7744c..6a90b80abef0 100644 --- a/cms/templates/push_to_lms.html +++ b/cms/templates/push_to_lms.html @@ -1,10 +1,12 @@ -<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%namespace name='static' file='static_content.html'/> -<%! from django.core.urlresolvers import reverse %> +<%! + from django.core.urlresolvers import reverse + from django.utils.translation import ugettext as _ +%> <%block name="title">${_("Push Course to LMS")} -<%block name="bodyclass">is-signedin course tools export +<%block name="bodyclass">is-signedin course tools view-push <%block name="content">
@@ -16,41 +18,51 @@

-
-
- -
-
-

${_("About Push to LMS")}

-

${_("Use this to export your course to its github site.")}

-

${_("This will then trigger an automatic update of the main LMS site")} - ${_("and update the contents of your course visible there to students.")}

- -
    -
  • ${_("Your course:")} ${context_course.id}
  • -
  • ${_("Course github site:")} ${context_course.lms.giturl}
  • - % if msg: -
  • msg:
    ${msg|n}
  • - % endif -
+
+
+
+
+

${_("About Push to LMS")}

+

${_("Use this to export your course to its git repository.")}

+

${_("This will then trigger an automatic update of the main LMS site and update the contents of your course visible there to students.")}

- -
-
-

${_("Push Course:")}

- - % if not context_course.lms.giturl: -

${_("giturl must be defined in your course settings before you can push to LMS")}

- % else: - ${_("Push to LMS")} - % endif - -
+
+

${_("Push Course:")}

+ + % if not context_course.giturl: +

${_("giturl must be defined in your course settings before you can push to LMS.")}

+ % else: + + % endif
- +
+ % if msg: + % if failed: +

${_('Export Failed')}:

+ % else: +

${_('Export Succeeded')}:

+ % endif +
${msg|h}
+ % endif +
-
+ +
diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 201bd599a6c0..6b39cfeaad37 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -104,11 +104,11 @@

${_("Tools")} ${_("Export")} - % if settings.MITX_FEATURES.get('ENABLE_PUSH_TO_LMS') and 'allow_push_to_lms' in context_course.advanced_modules: - - % endif + % if settings.FEATURES.get('ENABLE_PUSH_TO_LMS') and context_course.giturl: + + % endif

diff --git a/cms/urls.py b/cms/urls.py index 2360eadfb5b8..267869416cfd 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -99,13 +99,10 @@ if settings.FEATURES.get('ENABLE_PUSH_TO_LMS'): urlpatterns += (url(r'^(?P[^/]+)/(?P[^/]+)/push/(?P[^/]+)$', - 'contentstore.views.push_to_lms', name='push_to_lms'),) + 'contentstore.views.push_to_lms', name='push_to_lms'),) -if settings.ENABLE_JASMINE: - urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),) - -if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): - urlpatterns += ( +if settings.FEATURES.get('ENABLE_SERVICE_STATUS'): + urlpatterns += patterns('', url(r'^status/', include('service_status.urls')), ) diff --git a/scripts/cms_export_to_github b/scripts/cms_export_to_github deleted file mode 100755 index ac9eb973376b..000000000000 --- a/scripts/cms_export_to_github +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/python -# -# python script to export course from CMS to github -# -# Usage: python cms_export_to_github [] -# - -import os, sys, string, re -import datetime - -DIR = "/mnt/data_export" - -#----------------------------------------------------------------------------- -# usage - -def usage(): - print "Usage: python cms_export_to_github []" - -#----------------------------------------------------------------------------- - -try: - course_loc = sys.argv.pop(1) - username = sys.argv.pop(1) - repo = sys.argv.pop(1) -except: - usage() - sys.exit(0) - -if course_loc.startswith('i4x://'): - course_loc = course_loc[6:] - -m = re.match('git@[^ ]+\.git', repo) -if not m: - print "Oops, not a git ssh url?" - print repo - print "Expecting something like git@github.com:mitocw/edx4edx_lite.git" - sys.exit(-1) - -if len(sys.argv)>1: - rdir = sys.argv.pop(1) - rdir = os.path.basename(rdir) -else: - rdir = repo.rsplit('/',1)[-1].rsplit('.git',1)[0] -print "rdir = %s" % rdir - -rdirp = '%s/%s' % (DIR, rdir) -if os.path.exists(rdirp): - print "directory already exists, doing a git pull instead of git clone" - cmd = 'cd %s/%s; git pull' % (DIR, rdir) -else: - cmd = 'cd %s; git clone "%s"' % (DIR, repo) - -print cmd -ret_git = os.popen(cmd).read() -print ret_git - -if not os.path.exists('%s/%s' % (DIR, rdir)): - print "git clone failed!" - sys.exit(-1) - -#----------------------------------------------------------------------------- -# export course - -cmd = "./DJANGO-ADMIN-CMS export %s %s" % (course_loc, rdirp) -print cmd -ret_export = os.popen(cmd).read() -print ret_export - -#----------------------------------------------------------------------------- -# push to github - -dt = datetime.datetime.now() -cmd = 'cd %s; git add .; git commit -a -m "(%s) Export %s"; git push' % (rdirp, username, dt) -print cmd -ret_push = os.popen(cmd).read() -print ret_push - - diff --git a/scripts/cms_export_to_github_local b/scripts/cms_export_to_github_local deleted file mode 100755 index 6cb066b0cbb2..000000000000 --- a/scripts/cms_export_to_github_local +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -cd ~/mitx_all -source STARTUP -cd edx-platform - -python scripts/cms_export_to_github $* From 5e0f7816744ca2107b7604295997a73a9d8da534 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Wed, 22 Jan 2014 11:04:27 -0500 Subject: [PATCH 3/3] Major refactor and rename of feature All forward facing wording changed to Export to Git Export to git functions removed from management command and put in common file Additional error checking and documentation improvements Nitpicks and other minor fixes --- .../contentstore/git_export_utils.py | 185 ++++++++++++++++++ .../management/commands/git_export.py | 176 +---------------- .../commands/tests/test_git_export.py | 84 ++++---- ...test_push_to_lms.py => test_export_git.py} | 35 ++-- cms/djangoapps/contentstore/views/__init__.py | 2 +- .../views/{push_to_lms.py => export_git.py} | 18 +- cms/envs/test.py | 2 +- cms/static/sass/style-app-extend1.scss | 2 +- .../views/{_push.scss => _export-git.scss} | 23 ++- .../{push_to_lms.html => export_git.html} | 38 ++-- cms/templates/widgets/header.html | 6 +- cms/urls.py | 6 +- 12 files changed, 304 insertions(+), 273 deletions(-) create mode 100644 cms/djangoapps/contentstore/git_export_utils.py rename cms/djangoapps/contentstore/tests/{test_push_to_lms.py => test_export_git.py} (70%) rename cms/djangoapps/contentstore/views/{push_to_lms.py => export_git.py} (72%) rename cms/static/sass/views/{_push.scss => _export-git.scss} (84%) rename cms/templates/{push_to_lms.html => export_git.html} (54%) diff --git a/cms/djangoapps/contentstore/git_export_utils.py b/cms/djangoapps/contentstore/git_export_utils.py new file mode 100644 index 000000000000..e94b10d94c06 --- /dev/null +++ b/cms/djangoapps/contentstore/git_export_utils.py @@ -0,0 +1,185 @@ +""" +Utilities for export a course's XML into a git repository, +committing and pushing the changes. +""" + +import logging +import os +import subprocess +from urlparse import urlparse + +from django.conf import settings +from django.contrib.auth.models import User +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from xmodule.contentstore.django import contentstore +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.xml_exporter import export_to_xml + +log = logging.getLogger(__name__) + +GIT_REPO_EXPORT_DIR = getattr(settings, 'GIT_REPO_EXPORT_DIR', None) +GIT_EXPORT_DEFAULT_IDENT = getattr(settings, 'GIT_EXPORT_DEFAULT_IDENT', + {'name': 'STUDIO_EXPORT_TO_GIT', + 'email': 'STUDIO_EXPORT_TO_GIT@example.com'}) + + +class GitExportError(Exception): + """ + Convenience exception class for git export error conditions. + """ + + NO_EXPORT_DIR = _("GIT_REPO_EXPORT_DIR not set or path {0} doesn't exist, " + "please create it, or configure a different path with " + "GIT_REPO_EXPORT_DIR".format(GIT_REPO_EXPORT_DIR)) + URL_BAD = _('Non writable git url provided. Expecting something like:' + ' git@github.com:mitocw/edx4edx_lite.git') + URL_NO_AUTH = _('If using http urls, you must provide the username ' + 'and password in the url. Similar to ' + 'https://user:pass@github.com/user/course.') + DETACHED_HEAD = _('Unable to determine branch, repo in detached HEAD mode') + CANNOT_PULL = _('Unable to update or clone git repository.') + XML_EXPORT_FAIL = _('Unable to export course to xml.') + CONFIG_ERROR = _('Unable to configure git username and password') + CANNOT_COMMIT = _('Unable to commit changes. This is usually ' + 'because there are no changes to be committed') + CANNOT_PUSH = _('Unable to push changes. This is usually ' + 'because the remote repository cannot be contacted') + BAD_COURSE = _('Bad course location provided') + MISSING_BRANCH = _('Missing branch on fresh clone') + + +def cmd_log(cmd, cwd): + """ + Helper function to redirect stderr to stdout and log the command + used along with the output. Will raise subprocess.CalledProcessError if + command doesn't return 0, and returns the command's output. + """ + output = subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT) + log.debug(_('Command was: {0!r}. ' + 'Working directory was: {1!r}'.format(' '.join(cmd), cwd))) + log.debug(_('Command output was: {0!r}'.format(output))) + return output + + +def export_to_git(course_loc, repo, user='', rdir=None): + """Export a course to git.""" + # pylint: disable=R0915 + + if course_loc.startswith('i4x://'): + course_loc = course_loc[6:] + + if not GIT_REPO_EXPORT_DIR: + raise GitExportError(GitExportError.NO_EXPORT_DIR) + + if not os.path.isdir(GIT_REPO_EXPORT_DIR): + raise GitExportError(GitExportError.NO_EXPORT_DIR) + + # Check for valid writable git url + if not (repo.endswith('.git') or + repo.startswith(('http:', 'https:', 'file:'))): + raise GitExportError(GitExportError.URL_BAD) + + # Check for username and password if using http[s] + if repo.startswith('http:') or repo.startswith('https:'): + parsed = urlparse(repo) + if parsed.username is None or parsed.password is None: + raise GitExportError(GitExportError.URL_NO_AUTH) + if rdir: + rdir = os.path.basename(rdir) + else: + rdir = repo.rsplit('/', 1)[-1].rsplit('.git', 1)[0] + + log.debug("rdir = %s", rdir) + + # Pull or clone repo before exporting to xml + # and update url in case origin changed. + rdirp = '{0}/{1}'.format(GIT_REPO_EXPORT_DIR, rdir) + branch = None + if os.path.exists(rdirp): + log.info(_('Directory already exists, doing a git reset and pull ' + 'instead of git clone.')) + cwd = rdirp + # Get current branch + cmd = ['git', 'symbolic-ref', '--short', 'HEAD'] + try: + branch = cmd_log(cmd, cwd).strip('\n') + except subprocess.CalledProcessError as ex: + log.exception('Failed to get branch: %r', ex.output) + raise GitExportError(GitExportError.DETACHED_HEAD) + + cmds = [ + ['git', 'remote', 'set-url', 'origin', repo], + ['git', 'fetch', 'origin'], + ['git', 'reset', '--hard', 'origin/{0}'.format(branch)], + ['git', 'pull'], + ] + else: + cmds = [['git', 'clone', repo]] + cwd = GIT_REPO_EXPORT_DIR + + cwd = os.path.abspath(cwd) + for cmd in cmds: + try: + cmd_log(cmd, cwd) + except subprocess.CalledProcessError as ex: + log.exception('Failed to pull git repository: %r', ex.output) + raise GitExportError(GitExportError.CANNOT_PULL) + + # export course as xml before commiting and pushing + try: + location = CourseDescriptor.id_to_location(course_loc) + except ValueError: + raise GitExportError(GitExportError.BAD_COURSE) + + root_dir = os.path.dirname(rdirp) + course_dir = os.path.splitext(os.path.basename(rdirp))[0] + try: + export_to_xml(modulestore('direct'), contentstore(), location, + root_dir, course_dir, modulestore()) + except (EnvironmentError, AttributeError): + log.exception('Failed export to xml') + raise GitExportError(GitExportError.XML_EXPORT_FAIL) + + # Get current branch if not already set + if not branch: + cmd = ['git', 'symbolic-ref', '--short', 'HEAD'] + try: + branch = cmd_log(cmd, os.path.abspath(rdirp)).strip('\n') + except subprocess.CalledProcessError as ex: + log.exception('Failed to get branch from freshly cloned repo: %r', + ex.output) + raise GitExportError(GitExportError.MISSING_BRANCH) + + # Now that we have fresh xml exported, set identity, add + # everything to git, commit, and push to the right branch. + ident = {} + try: + user = User.objects.get(username=user) + ident['name'] = user.username + ident['email'] = user.email + except User.DoesNotExist: + # That's ok, just use default ident + ident = GIT_EXPORT_DEFAULT_IDENT + time_stamp = timezone.now() + cwd = os.path.abspath(rdirp) + commit_msg = 'Export from Studio at {1}'.format(user, time_stamp) + try: + cmd_log(['git', 'config', 'user.email', ident['email']], cwd) + cmd_log(['git', 'config', 'user.name', ident['name']], cwd) + except subprocess.CalledProcessError as ex: + log.exception('Error running git configure commands: %r', ex.output) + raise GitExportError(GitExportError.CONFIG_ERROR) + try: + cmd_log(['git', 'add', '.'], cwd) + cmd_log(['git', 'commit', '-a', '-m', commit_msg], cwd) + except subprocess.CalledProcessError as ex: + log.exception('Unable to commit changes: %r', ex.output) + raise GitExportError(GitExportError.CANNOT_COMMIT) + try: + cmd_log(['git', 'push', '-q', 'origin', branch], cwd) + except subprocess.CalledProcessError as ex: + log.exception('Error running git push command: %r', ex.output) + raise GitExportError(GitExportError.CANNOT_PUSH) diff --git a/cms/djangoapps/contentstore/management/commands/git_export.py b/cms/djangoapps/contentstore/management/commands/git_export.py index 47ac1d74273c..848ef832e7cf 100644 --- a/cms/djangoapps/contentstore/management/commands/git_export.py +++ b/cms/djangoapps/contentstore/management/commands/git_export.py @@ -10,178 +10,19 @@ any have taken place. This functionality is also available as an export view in studio if the giturl -attribute is set and the FEATURE['ENABLE_PUSH_TO_LMS'] is set. +attribute is set and the FEATURE['ENABLE_EXPORT_GIT'] is set. """ import logging from optparse import make_option -import os -import subprocess -from urlparse import urlparse -from django.conf import settings -from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError -from django.utils import timezone from django.utils.translation import ugettext as _ -from xmodule.contentstore.django import contentstore -from xmodule.course_module import CourseDescriptor -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.xml_exporter import export_to_xml +import contentstore.git_export_utils as git_export_utils log = logging.getLogger(__name__) -GIT_REPO_EXPORT_DIR = getattr(settings, 'GIT_REPO_EXPORT_DIR', - '/edx/var/edxapp/export_course_repos') -GIT_EXPORT_DEFAULT_IDENT = getattr(settings, 'GIT_EXPORT_DEFAULT_IDENT', - {'name': 'STUDIO_PUSH_TO_LMS', - 'email': 'STUDIO_PUSH_TO_LMS@example.com'}) - - -class GitExportError(Exception): - """ - Convenience exception class for git export error conditions. - """ - - NO_EXPORT_DIR = _("Path {0} doesn't exist, please create it, " - "or configure a different path with " - "GIT_REPO_EXPORT_DIR").format(GIT_REPO_EXPORT_DIR) - URL_BAD = _('Non writable git url provided. Expecting something like:' - ' git@github.com:mitocw/edx4edx_lite.git') - URL_NO_AUTH = _('If using http urls, you must provide the username ' - 'and password in the url. Similar to ' - 'https://user:pass@github.com/user/course.') - DETACHED_HEAD = _('Unable to determine branch, repo in detached HEAD mode') - CANNOT_PULL = _('Unable to update or clone git repository.') - XML_EXPORT_FAIL = _('Unable to export course to xml.') - CANNOT_COMMIT = _('Unable to commit or push changes.') - BAD_COURSE = _('Bad course location provided') - MISSING_BRANCH = _('Missing branch on fresh clone') - - -def cmd_log(cmd, cwd): - """ - Helper function to redirect stderr to stdout and log the command - used along with the output. Will raise subprocess.CalledProcessError if - command doesn't return 0, and returns the command's output. - """ - output = subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT) - log.debug(_('Command was: {0!r}. ' - 'Working directory was: {1!r}').format(' '.join(cmd), cwd)) - log.debug(_('Command output was: {0!r}'.format(output))) - return output - - -def export_to_git(course_loc, repo, user='', rdir=None): - """Export a course to git.""" - # pylint: disable=R0915 - - if course_loc.startswith('i4x://'): - course_loc = course_loc[6:] - - if not os.path.isdir(GIT_REPO_EXPORT_DIR): - raise GitExportError(GitExportError.NO_EXPORT_DIR) - - # Check for valid writable git url - if not (repo.endswith('.git') or - repo.startswith(('http:', 'https:', 'file:'))): - raise GitExportError(GitExportError.URL_BAD) - - # Check for username and password if using http[s] - if repo.startswith('http:') or repo.startswith('https:'): - parsed = urlparse(repo) - if parsed.username is None or parsed.password is None: - raise GitExportError(GitExportError.URL_NO_AUTH) - if rdir: - rdir = os.path.basename(rdir) - else: - rdir = repo.rsplit('/', 1)[-1].rsplit('.git', 1)[0] - - log.debug("rdir = %s", rdir) - - # Pull or clone repo before exporting to xml - # and update url in case origin changed. - rdirp = '{0}/{1}'.format(GIT_REPO_EXPORT_DIR, rdir) - branch = None - if os.path.exists(rdirp): - log.info(_('Directory already exists, doing a git reset and pull ' - 'instead of git clone.')) - cwd = rdirp - # Get current branch - cmd = ['git', 'symbolic-ref', '--short', 'HEAD', ] - try: - branch = cmd_log(cmd, cwd).strip('\n') - except subprocess.CalledProcessError as ex: - log.exception('Failed to get branch: %r', ex.output) - raise GitExportError(GitExportError.DETACHED_HEAD) - - cmds = [ - ['git', 'remote', 'set-url', 'origin', repo, ], - ['git', 'fetch', 'origin', ], - ['git', 'reset', '--hard', 'origin/{0}'.format(branch), ], - ['git', 'pull', ], - ] - else: - cmds = [['git', 'clone', repo, ], ] - cwd = GIT_REPO_EXPORT_DIR - - cwd = os.path.abspath(cwd) - for cmd in cmds: - try: - cmd_log(cmd, cwd) - except subprocess.CalledProcessError as ex: - log.exception('Failed to pull git repository: %r', ex.output) - raise GitExportError(GitExportError.CANNOT_PULL) - - # export course as xml before commiting and pushing - try: - location = CourseDescriptor.id_to_location(course_loc) - except ValueError: - raise GitExportError(GitExportError.BAD_COURSE) - - root_dir = os.path.dirname(rdirp) - course_dir = os.path.splitext(os.path.basename(rdirp))[0] - try: - export_to_xml(modulestore('direct'), contentstore(), location, - root_dir, course_dir, modulestore()) - except (EnvironmentError, AttributeError): - log.exception('Failed export to xml') - raise GitExportError(GitExportError.XML_EXPORT_FAIL) - - # Get current branch if not already set - if not branch: - cmd = ['git', 'symbolic-ref', '--short', 'HEAD', ] - try: - branch = cmd_log(cmd, os.path.abspath(rdirp)).strip('\n') - except subprocess.CalledProcessError as ex: - log.exception('Failed to get branch from freshly cloned repo: %r', - ex.output) - raise GitExportError(GitExportError.MISSING_BRANCH) - - # Now that we have fresh xml exported, set identity, add - # everything to git, commit, and push to the right branch. - ident = {} - try: - user = User.objects.get(username=user) - ident['name'] = user.username - ident['email'] = user.email - except User.DoesNotExist: - # That's ok, just use default ident - ident = GIT_EXPORT_DEFAULT_IDENT - time_stamp = timezone.now() - cwd = os.path.abspath(rdirp) - commit_msg = 'Export from Studio at {1}'.format(user, time_stamp) - try: - cmd_log(['git', 'config', 'user.email', ident['email'], ], cwd) - cmd_log(['git', 'config', 'user.name', ident['name'], ], cwd) - cmd_log(['git', 'add', '.'], cwd) - cmd_log(['git', 'commit', '-a', '-m', commit_msg], cwd) - cmd_log(['git', 'push', '-q', 'origin', branch], cwd) - except subprocess.CalledProcessError as ex: - log.exception('Error running git push commands: %r', ex.output) - raise GitExportError(GitExportError.CANNOT_COMMIT) - class Command(BaseCommand): """ @@ -189,8 +30,9 @@ class Command(BaseCommand): """ option_list = BaseCommand.option_list + ( - make_option('--user', '-u', dest='user', - help='Add a user to the commit message.'), + make_option('--username', '-u', dest='user', + help=('Specify a username from LMS/Studio to be used ' + 'as the commit author.')), make_option('--repo_dir', '-r', dest='repo', help='Specify existing git repo directory.'), ) @@ -206,16 +48,16 @@ def handle(self, *args, **options): """ if len(args) != 2: - raise CommandError(_('This script requires exactly two arguments: ' - 'course_loc and git_url')) + raise CommandError('This script requires exactly two arguments: ' + 'course_loc and git_url') # Rethrow GitExportError as CommandError for SystemExit try: - export_to_git( + git_export_utils.export_to_git( args[0], args[1], options.get('user', ''), options.get('rdir', None) ) - except GitExportError as ex: + except git_export_utils.GitExportError as ex: raise CommandError(str(ex)) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py b/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py index 6d62dcddfc32..228571035a12 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py @@ -16,17 +16,17 @@ from django.test.utils import override_settings from contentstore.tests.utils import CourseTestCase -import contentstore.management.commands.git_export as git_export -from contentstore.management.commands.git_export import GitExportError +import contentstore.git_export_utils as git_export_utils +from contentstore.git_export_utils import GitExportError -FEATURES_WITH_PUSH_TO_LMS = settings.FEATURES.copy() -FEATURES_WITH_PUSH_TO_LMS['ENABLE_PUSH_TO_LMS'] = True +FEATURES_WITH_EXPORT_GIT = settings.FEATURES.copy() +FEATURES_WITH_EXPORT_GIT['ENABLE_EXPORT_GIT'] = True TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) -@override_settings(FEATURES=FEATURES_WITH_PUSH_TO_LMS) +@override_settings(FEATURES=FEATURES_WITH_EXPORT_GIT) class TestGitExport(CourseTestCase): """ Excercise the git_export django management command with various inputs. @@ -38,16 +38,16 @@ def setUp(self): """ super(TestGitExport, self).setUp() - if not os.path.isdir(git_export.GIT_REPO_EXPORT_DIR): - os.mkdir(git_export.GIT_REPO_EXPORT_DIR) - self.addCleanup(shutil.rmtree, git_export.GIT_REPO_EXPORT_DIR) + if not os.path.isdir(git_export_utils.GIT_REPO_EXPORT_DIR): + os.mkdir(git_export_utils.GIT_REPO_EXPORT_DIR) + self.addCleanup(shutil.rmtree, git_export_utils.GIT_REPO_EXPORT_DIR) self.bare_repo_dir = '{0}/data/test_bare.git'.format( os.path.abspath(settings.TEST_ROOT)) if not os.path.isdir(self.bare_repo_dir): os.mkdir(self.bare_repo_dir) self.addCleanup(shutil.rmtree, self.bare_repo_dir) - subprocess.check_output(['git', '--bare', 'init', ], + subprocess.check_output(['git', '--bare', 'init'], cwd=self.bare_repo_dir) def test_command(self): @@ -56,64 +56,62 @@ def test_command(self): test output. """ with self.assertRaises(SystemExit) as ex: - self.assertRaisesRegexp( - CommandError, 'This script requires.*', + with self.assertRaisesRegexp(CommandError, 'This script requires.*'): call_command('git_export', 'blah', 'blah', 'blah', - stderr=StringIO.StringIO())) + stderr=StringIO.StringIO()) self.assertEqual(ex.exception.code, 1) with self.assertRaises(SystemExit) as ex: - self.assertRaisesRegexp(CommandError, 'This script requires.*', - call_command('git_export', - stderr=StringIO.StringIO())) + with self.assertRaisesRegexp(CommandError, 'This script requires.*'): + call_command('git_export', stderr=StringIO.StringIO()) self.assertEqual(ex.exception.code, 1) # Send bad url to get course not exported with self.assertRaises(SystemExit) as ex: - self.assertRaisesRegexp(CommandError, GitExportError.URL_BAD, - call_command('git_export', 'foo', 'silly', - stderr=StringIO.StringIO())) + with self.assertRaisesRegexp(CommandError, GitExportError.URL_BAD): + call_command('git_export', 'foo', 'silly', + stderr=StringIO.StringIO()) self.assertEqual(ex.exception.code, 1) def test_bad_git_url(self): """ Test several bad URLs for validation """ - with self.assertRaisesRegexp(GitExportError, GitExportError.URL_BAD): - git_export.export_to_git('', 'Sillyness') + with self.assertRaisesRegexp(GitExportError, str(GitExportError.URL_BAD)): + git_export_utils.export_to_git('', 'Sillyness') - with self.assertRaisesRegexp(GitExportError, GitExportError.URL_BAD): - git_export.export_to_git('', 'example.com:edx/notreal') + with self.assertRaisesRegexp(GitExportError, str(GitExportError.URL_BAD)): + git_export_utils.export_to_git('', 'example.com:edx/notreal') with self.assertRaisesRegexp(GitExportError, - GitExportError.URL_NO_AUTH): - git_export.export_to_git('', 'http://blah') + str(GitExportError.URL_NO_AUTH)): + git_export_utils.export_to_git('', 'http://blah') def test_bad_git_repos(self): """ Test invalid git repos """ - test_repo_path = '{}/test_repo'.format(git_export.GIT_REPO_EXPORT_DIR) + test_repo_path = '{}/test_repo'.format(git_export_utils.GIT_REPO_EXPORT_DIR) self.assertFalse(os.path.isdir(test_repo_path)) # Test bad clones with self.assertRaisesRegexp(GitExportError, - GitExportError.CANNOT_PULL): - git_export.export_to_git( + str(GitExportError.CANNOT_PULL)): + git_export_utils.export_to_git( 'foo/blah/100', 'https://user:blah@example.com/test_repo.git') self.assertFalse(os.path.isdir(test_repo_path)) # Setup good repo with bad course to test xml export with self.assertRaisesRegexp(GitExportError, - GitExportError.XML_EXPORT_FAIL): - git_export.export_to_git( + str(GitExportError.XML_EXPORT_FAIL)): + git_export_utils.export_to_git( 'foo/blah/100', 'file://{0}'.format(self.bare_repo_dir)) # Test bad git remote after successful clone with self.assertRaisesRegexp(GitExportError, - GitExportError.CANNOT_PULL): - git_export.export_to_git( + str(GitExportError.CANNOT_PULL)): + git_export_utils.export_to_git( 'foo/blah/100', 'https://user:blah@example.com/r.git') @@ -121,8 +119,8 @@ def test_bad_course_id(self): """ Test valid git url, but bad course. """ - with self.assertRaisesRegexp(GitExportError, GitExportError.BAD_COURSE): - git_export.export_to_git( + with self.assertRaisesRegexp(GitExportError, str(GitExportError.BAD_COURSE)): + git_export_utils.export_to_git( '', 'file://{0}'.format(self.bare_repo_dir), '', '/blah') @unittest.skipIf(os.environ.get('GIT_CONFIG') or @@ -138,23 +136,23 @@ def test_git_ident(self): Test skipped if git global config override environment variable GIT_CONFIG is set. """ - git_export.export_to_git( + git_export_utils.export_to_git( self.course.id, 'file://{0}'.format(self.bare_repo_dir), 'enigma' ) expect_string = '{0}|{1}\n'.format( - git_export.GIT_EXPORT_DEFAULT_IDENT['name'], - git_export.GIT_EXPORT_DEFAULT_IDENT['email'] + git_export_utils.GIT_EXPORT_DEFAULT_IDENT['name'], + git_export_utils.GIT_EXPORT_DEFAULT_IDENT['email'] ) - cwd = os.path.abspath(git_export.GIT_REPO_EXPORT_DIR / 'test_bare') + cwd = os.path.abspath(git_export_utils.GIT_REPO_EXPORT_DIR / 'test_bare') git_log = subprocess.check_output(['git', 'log', '-1', - '--format=%an|%ae', ], cwd=cwd) + '--format=%an|%ae'], cwd=cwd) self.assertEqual(expect_string, git_log) # Make changes to course so there is something commit self.populateCourse() - git_export.export_to_git( + git_export_utils.export_to_git( self.course.id, 'file://{0}'.format(self.bare_repo_dir), self.user.username @@ -164,19 +162,19 @@ def test_git_ident(self): self.user.email, ) git_log = subprocess.check_output( - ['git', 'log', '-1', '--format=%an|%ae', ], cwd=cwd) + ['git', 'log', '-1', '--format=%an|%ae'], cwd=cwd) self.assertEqual(expect_string, git_log) def test_no_change(self): """ Test response if there are no changes """ - git_export.export_to_git( + git_export_utils.export_to_git( 'i4x://{0}'.format(self.course.id), 'file://{0}'.format(self.bare_repo_dir) ) with self.assertRaisesRegexp(GitExportError, - GitExportError.CANNOT_COMMIT): - git_export.export_to_git( + str(GitExportError.CANNOT_COMMIT)): + git_export_utils.export_to_git( self.course.id, 'file://{0}'.format(self.bare_repo_dir)) diff --git a/cms/djangoapps/contentstore/tests/test_push_to_lms.py b/cms/djangoapps/contentstore/tests/test_export_git.py similarity index 70% rename from cms/djangoapps/contentstore/tests/test_push_to_lms.py rename to cms/djangoapps/contentstore/tests/test_export_git.py index fc7c04c5ce6d..6288a7ef008d 100644 --- a/cms/djangoapps/contentstore/tests/test_push_to_lms.py +++ b/cms/djangoapps/contentstore/tests/test_export_git.py @@ -11,10 +11,11 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.test.utils import override_settings -from django.utils.translation import ugettext as _ +from pymongo import MongoClient from .utils import CourseTestCase -import contentstore.management.commands.git_export as git_export +import contentstore.git_export_utils as git_export_utils +from xmodule.contentstore.django import _CONTENTSTORE from xmodule.modulestore.django import modulestore TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) @@ -22,7 +23,7 @@ @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) -class TestPushToLMS(CourseTestCase): +class TestExportGit(CourseTestCase): """ Tests pushing a course to a git repository """ @@ -31,14 +32,18 @@ def setUp(self): """ Setup test course, user, and url. """ - super(TestPushToLMS, self).setUp() + super(TestExportGit, self).setUp() self.course_module = modulestore().get_item(self.course.location) - self.test_url = reverse('push_to_lms', kwargs={ + self.test_url = reverse('export_git', kwargs={ 'org': self.course.location.org, 'course': self.course.location.course, 'name': self.course.location.name, }) + def tearDown(self): + MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db']) + _CONTENTSTORE.clear() + def test_giturl_missing(self): """ Test to make sure an appropriate error is displayed @@ -47,20 +52,20 @@ def test_giturl_missing(self): response = self.client.get(self.test_url) self.assertEqual(200, response.status_code) self.assertIn( - _('giturl must be defined in your ' - 'course settings before you can push to LMS.'), + ('giturl must be defined in your ' + 'course settings before you can export to git.'), response.content ) response = self.client.get('{}?action=push'.format(self.test_url)) self.assertEqual(200, response.status_code) self.assertIn( - _('giturl must be defined in your ' - 'course settings before you can push to LMS.'), + ('giturl must be defined in your ' + 'course settings before you can export to git.'), response.content ) - def test_course_import_failures(self): + def test_course_export_failures(self): """ Test failed course export response. """ @@ -68,19 +73,19 @@ def test_course_import_failures(self): modulestore().save_xmodule(self.course_module) response = self.client.get('{}?action=push'.format(self.test_url)) - self.assertIn(_('Export Failed:'), response.content) + self.assertIn('Export Failed:', response.content) - def test_course_import_success(self): + def test_course_export_success(self): """ Test successful course export response. """ # Build out local bare repo, and set course git url to it - repo_dir = os.path.abspath(git_export.GIT_REPO_EXPORT_DIR) + repo_dir = os.path.abspath(git_export_utils.GIT_REPO_EXPORT_DIR) os.mkdir(repo_dir) self.addCleanup(shutil.rmtree, repo_dir) bare_repo_dir = '{0}/test_repo.git'.format( - os.path.abspath(git_export.GIT_REPO_EXPORT_DIR)) + os.path.abspath(git_export_utils.GIT_REPO_EXPORT_DIR)) os.mkdir(bare_repo_dir) self.addCleanup(shutil.rmtree, bare_repo_dir) @@ -91,4 +96,4 @@ def test_course_import_success(self): modulestore().save_xmodule(self.course_module) response = self.client.get('{}?action=push'.format(self.test_url)) - self.assertIn(_('Export Succeeded'), response.content) + self.assertIn('Export Succeeded', response.content) diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py index 7458a883c8a1..5e644468fdb3 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -14,7 +14,7 @@ from .import_export import * from .preview import * from .public import * -from .push_to_lms import * +from .export_git import * from .user import * from .tabs import * from .transcripts_ajax import * diff --git a/cms/djangoapps/contentstore/views/push_to_lms.py b/cms/djangoapps/contentstore/views/export_git.py similarity index 72% rename from cms/djangoapps/contentstore/views/push_to_lms.py rename to cms/djangoapps/contentstore/views/export_git.py index 509414527508..e3ec9f4f6085 100644 --- a/cms/djangoapps/contentstore/views/push_to_lms.py +++ b/cms/djangoapps/contentstore/views/export_git.py @@ -10,44 +10,44 @@ from django_future.csrf import ensure_csrf_cookie from django.utils.translation import ugettext as _ -from .access import has_access +from .access import has_course_access +import contentstore.git_export_utils as git_export_utils from edxmako.shortcuts import render_to_response from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -import contentstore.management.commands.git_export as git_export log = logging.getLogger(__name__) @ensure_csrf_cookie @login_required -def push_to_lms(request, org, course, name): +def export_git(request, org, course, name): """ - This method serves up the 'Push to LMS' page + This method serves up the 'Export to Git' page """ location = Location('i4x', org, course, 'course', name) - if not has_access(request.user, location): + if not has_course_access(request.user, location): raise PermissionDenied() course_module = modulestore().get_item(location) failed = False - log.debug('push_to_lms course_module=%s', course_module) + log.debug('export_git course_module=%s', course_module) msg = "" if 'action' in request.GET and course_module.giturl: if request.GET['action'] == 'push': try: - git_export.export_to_git( + git_export_utils.export_to_git( course_module.id, course_module.giturl, request.user, ) msg = _('Course successfully exported to git repository') - except git_export.GitExportError as ex: + except git_export_utils.GitExportError as ex: failed = True msg = str(ex) - return render_to_response('push_to_lms.html', { + return render_to_response('export_git.html', { 'context_course': course_module, 'msg': msg, 'failed': failed, diff --git a/cms/envs/test.py b/cms/envs/test.py index 6aca0f7ccb7e..5edbc896866c 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -38,7 +38,7 @@ COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data" # For testing "push to lms" -FEATURES['ENABLE_PUSH_TO_LMS'] = True +FEATURES['ENABLE_EXPORT_GIT'] = True GIT_REPO_EXPORT_DIR = TEST_ROOT / "export_course_repos" # Makes the tests run much faster... diff --git a/cms/static/sass/style-app-extend1.scss b/cms/static/sass/style-app-extend1.scss index 3cb62450376e..2702157ee642 100644 --- a/cms/static/sass/style-app-extend1.scss +++ b/cms/static/sass/style-app-extend1.scss @@ -44,7 +44,7 @@ @import 'views/users'; @import 'views/checklists'; @import 'views/textbooks'; -@import 'views/push'; +@import 'views/export-git'; // base - contexts @import 'contexts/ie'; // ie-specific rules (mostly for known/older bugs) diff --git a/cms/static/sass/views/_push.scss b/cms/static/sass/views/_export-git.scss similarity index 84% rename from cms/static/sass/views/_push.scss rename to cms/static/sass/views/_export-git.scss index 743d53a586ff..dda371b45ea7 100644 --- a/cms/static/sass/views/_push.scss +++ b/cms/static/sass/views/_export-git.scss @@ -1,7 +1,7 @@ -// studio - views - push to lms +// studio - views - export to git // ==================== -.view-push { +.view-export-git { // UI: basic layout .content-primary, .content-supplementary { @@ -24,24 +24,24 @@ h3 { font-size: 19px; - font-weight: 700; + font-weight: 700; } - .push-info-block { + .export-git-info-block { dt { font-size: 19px; font-weight: 700; - margin-top: 12px; + margin-top: 12px; } dd { font-size: 17px; - margin-bottom: 20px; + margin-bottom: 20px; } - + .course_text { - color: $green; + color: $green; } .giturl_text { color: $blue; @@ -57,7 +57,7 @@ } // UI: export controls - .push-controls { + .export-git-controls { @include box-sizing(border-box); @extend %ui-window; padding: $baseline ($baseline*1.5) ($baseline*1.5) ($baseline*1.5); @@ -66,7 +66,7 @@ @extend %t-title4; } - .action-push { + .action-export-git { @extend %btn-primary-blue; @extend %t-action1; display: block; @@ -87,7 +87,6 @@ display: inline-block; vertical-align: middle; } - } + } } } - diff --git a/cms/templates/push_to_lms.html b/cms/templates/export_git.html similarity index 54% rename from cms/templates/push_to_lms.html rename to cms/templates/export_git.html index 6a90b80abef0..5e5456949622 100644 --- a/cms/templates/push_to_lms.html +++ b/cms/templates/export_git.html @@ -1,50 +1,52 @@ <%inherit file="base.html" /> <%namespace name='static' file='static_content.html'/> -<%! +<%! from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ %> -<%block name="title">${_("Push Course to LMS")} -<%block name="bodyclass">is-signedin course tools view-push +<%block name="title">${_("Export Course to Git")} +<%block name="bodyclass">is-signedin course tools view-export-git <%block name="content">

${_("Tools")} - > ${_("Push to LMS")} + > ${_("Export to Git")}

-
+
-

${_("About Push to LMS")}

-

${_("Use this to export your course to its git repository.")}

-

${_("This will then trigger an automatic update of the main LMS site and update the contents of your course visible there to students.")}

+

${_("About Export to Git")}

+
+

${_("Use this to export your course to its git repository.")}

+

${_("This will then trigger an automatic update of the main LMS site and update the contents of your course visible there to students if automatic git imports are configured.")}

+
-
-

${_("Push Course:")}

- +
+

${_("Export Course to Git:")}

+ % if not context_course.giturl: -

${_("giturl must be defined in your course settings before you can push to LMS.")}

+

${_("giturl must be defined in your course settings before you can export to git.")}

% else: % endif
-
+
% if msg: % if failed:

${_('Export Failed')}:

@@ -53,16 +55,16 @@

${_('Export Succeeded')}:

% endif
${msg|h}
% endif -
+
+
diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 6b39cfeaad37..0dcac37f6a12 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -104,9 +104,9 @@

${_("Tools")} ${_("Export")} - % if settings.FEATURES.get('ENABLE_PUSH_TO_LMS') and context_course.giturl: - % endif diff --git a/cms/urls.py b/cms/urls.py index 267869416cfd..8192b458861f 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -97,9 +97,9 @@ ) -if settings.FEATURES.get('ENABLE_PUSH_TO_LMS'): - urlpatterns += (url(r'^(?P[^/]+)/(?P[^/]+)/push/(?P[^/]+)$', - 'contentstore.views.push_to_lms', name='push_to_lms'),) +if settings.FEATURES.get('ENABLE_EXPORT_GIT'): + urlpatterns += (url(r'^(?P[^/]+)/(?P[^/]+)/export_git/(?P[^/]+)$', + 'contentstore.views.export_git', name='export_git'),) if settings.FEATURES.get('ENABLE_SERVICE_STATUS'): urlpatterns += patterns('',