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
This commit is contained in:
185
cms/djangoapps/contentstore/git_export_utils.py
Normal file
185
cms/djangoapps/contentstore/git_export_utils.py
Normal file
@@ -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)
|
||||
@@ -10,178 +10,19 @@ 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.
|
||||
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 @@ class Command(BaseCommand):
|
||||
"""
|
||||
|
||||
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))
|
||||
|
||||
@@ -16,17 +16,17 @@ 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
|
||||
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 @@ class TestGitExport(CourseTestCase):
|
||||
"""
|
||||
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 @@ class TestGitExport(CourseTestCase):
|
||||
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 @@ class TestGitExport(CourseTestCase):
|
||||
"""
|
||||
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 @@ class TestGitExport(CourseTestCase):
|
||||
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 @@ class TestGitExport(CourseTestCase):
|
||||
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))
|
||||
|
||||
@@ -11,10 +11,11 @@ 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 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 @@ TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
|
||||
class TestPushToLMS(CourseTestCase):
|
||||
class TestExportGit(CourseTestCase):
|
||||
"""
|
||||
Tests pushing a course to a git repository
|
||||
"""
|
||||
@@ -31,14 +32,18 @@ class TestPushToLMS(CourseTestCase):
|
||||
"""
|
||||
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 @@ class TestPushToLMS(CourseTestCase):
|
||||
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 @@ class TestPushToLMS(CourseTestCase):
|
||||
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 @@ class TestPushToLMS(CourseTestCase):
|
||||
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)
|
||||
@@ -14,7 +14,7 @@ from .item import *
|
||||
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 *
|
||||
|
||||
@@ -10,44 +10,44 @@ from django.core.exceptions import PermissionDenied
|
||||
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,
|
||||
@@ -38,7 +38,7 @@ 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
|
||||
FEATURES['ENABLE_EXPORT_GIT'] = True
|
||||
GIT_REPO_EXPORT_DIR = TEST_ROOT / "export_course_repos"
|
||||
|
||||
# Makes the tests run much faster...
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<%block name="bodyclass">is-signedin course tools view-push</%block>
|
||||
<%block name="title">${_("Export Course to Git")}</%block>
|
||||
<%block name="bodyclass">is-signedin course tools view-export-git</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-subtitle">
|
||||
<h1 class="page-header">
|
||||
<small class="subtitle">${_("Tools")}</small>
|
||||
<span class="sr">> </span>${_("Push to LMS")}
|
||||
<span class="sr">> </span>${_("Export to Git")}
|
||||
</h1>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-master wrapper">
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
|
||||
<div class="introduction">
|
||||
<h2 class="title">${_("About Push to LMS")}</h2>
|
||||
<p>${_("Use this to export your course to its git repository.")}</p>
|
||||
<p>${_("This will then trigger an automatic update of the main LMS site and update the contents of your course visible there to students.")}</p>
|
||||
<h2 class="title">${_("About Export to Git")}</h2>
|
||||
<div class="copy">
|
||||
<p>${_("Use this to export your course to its git repository.")}</p>
|
||||
<p>${_("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.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="push-controls">
|
||||
<h2 class="title">${_("Push Course:")}</h2>
|
||||
|
||||
<div class="export-git-controls">
|
||||
<h2 class="title">${_("Export Course to Git:")}</h2>
|
||||
|
||||
% if not context_course.giturl:
|
||||
<p class="error-text">${_("giturl must be defined in your course settings before you can push to LMS.")}</p>
|
||||
<p class="error-text">${_("giturl must be defined in your course settings before you can export to git.")}</p>
|
||||
% else:
|
||||
<ul class="list-actions">
|
||||
<li class="item-action">
|
||||
<a class="action action-push action-primary" href="${reverse('push_to_lms', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}?action=push">
|
||||
<a class="action action-export-git"" action-primary" href="${reverse('export_git', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}?action=push">
|
||||
<i class="icon-download"></i>
|
||||
<span class="copy">${_("Push to LMS")}</span>
|
||||
<span class="copy">${_("Export to Git")}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
% endif
|
||||
</div>
|
||||
<div class="messages">
|
||||
<div class="messages">
|
||||
% if msg:
|
||||
% if failed:
|
||||
<h3 class="error-text">${_('Export Failed')}:</h3>
|
||||
@@ -53,16 +55,16 @@
|
||||
% endif
|
||||
<pre>${msg|h}</pre>
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<dl class='push-info-block'>
|
||||
<dl class="export-git-info-block">
|
||||
<dt>${_("Your course:")}</dt>
|
||||
<dd class="course_text">${context_course.id}</dd>
|
||||
<dt>${_("Course git url:")}</dt>
|
||||
<dd class="giturl_text">${context_course.giturl}</dd>
|
||||
</dl>
|
||||
</aside>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -104,9 +104,9 @@
|
||||
<li class="nav-item nav-course-tools-export">
|
||||
<a href="${export_url}">${_("Export")}</a>
|
||||
</li>
|
||||
% if settings.FEATURES.get('ENABLE_PUSH_TO_LMS') and context_course.giturl:
|
||||
<li class="nav-item nav-course-tools-push">
|
||||
<a href="${reverse('push_to_lms', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Push to LMS")}</a>
|
||||
% if settings.FEATURES.get('ENABLE_EXPORT_GIT') and context_course.giturl:
|
||||
<li class="nav-item nav-course-tools-export-git">
|
||||
<a href="${reverse('export_git', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Export to Git")}</a>
|
||||
</li>
|
||||
% endif
|
||||
</ul>
|
||||
|
||||
@@ -97,9 +97,9 @@ urlpatterns += patterns('',
|
||||
)
|
||||
|
||||
|
||||
if settings.FEATURES.get('ENABLE_PUSH_TO_LMS'):
|
||||
urlpatterns += (url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/push/(?P<name>[^/]+)$',
|
||||
'contentstore.views.push_to_lms', name='push_to_lms'),)
|
||||
if settings.FEATURES.get('ENABLE_EXPORT_GIT'):
|
||||
urlpatterns += (url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/export_git/(?P<name>[^/]+)$',
|
||||
'contentstore.views.export_git', name='export_git'),)
|
||||
|
||||
if settings.FEATURES.get('ENABLE_SERVICE_STATUS'):
|
||||
urlpatterns += patterns('',
|
||||
|
||||
Reference in New Issue
Block a user