Merge pull request #718 from edx/feature/ichuang/push-to-lms
Let a Studio user export course to git (and via git, to elsewhere, eg LMS)
This commit is contained in:
@@ -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.
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
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_EXPORT_GIT'] is set.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from optparse import make_option
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
import contentstore.git_export_utils as git_export_utils
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Take a course from studio and export it to a git repository.
|
||||
"""
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
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.'),
|
||||
)
|
||||
|
||||
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 <course_loc> <git_url>')
|
||||
|
||||
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:
|
||||
git_export_utils.export_to_git(
|
||||
args[0],
|
||||
args[1],
|
||||
options.get('user', ''),
|
||||
options.get('rdir', None)
|
||||
)
|
||||
except git_export_utils.GitExportError as ex:
|
||||
raise CommandError(str(ex))
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
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.git_export_utils as git_export_utils
|
||||
from contentstore.git_export_utils import GitExportError
|
||||
|
||||
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_EXPORT_GIT)
|
||||
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_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'],
|
||||
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:
|
||||
with 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:
|
||||
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:
|
||||
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, str(GitExportError.URL_BAD)):
|
||||
git_export_utils.export_to_git('', 'Sillyness')
|
||||
|
||||
with self.assertRaisesRegexp(GitExportError, str(GitExportError.URL_BAD)):
|
||||
git_export_utils.export_to_git('', 'example.com:edx/notreal')
|
||||
|
||||
with self.assertRaisesRegexp(GitExportError,
|
||||
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_utils.GIT_REPO_EXPORT_DIR)
|
||||
self.assertFalse(os.path.isdir(test_repo_path))
|
||||
# Test bad clones
|
||||
with self.assertRaisesRegexp(GitExportError,
|
||||
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,
|
||||
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,
|
||||
str(GitExportError.CANNOT_PULL)):
|
||||
git_export_utils.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, 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
|
||||
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_utils.export_to_git(
|
||||
self.course.id,
|
||||
'file://{0}'.format(self.bare_repo_dir),
|
||||
'enigma'
|
||||
)
|
||||
expect_string = '{0}|{1}\n'.format(
|
||||
git_export_utils.GIT_EXPORT_DEFAULT_IDENT['name'],
|
||||
git_export_utils.GIT_EXPORT_DEFAULT_IDENT['email']
|
||||
)
|
||||
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)
|
||||
self.assertEqual(expect_string, git_log)
|
||||
|
||||
# Make changes to course so there is something commit
|
||||
self.populateCourse()
|
||||
git_export_utils.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_utils.export_to_git(
|
||||
'i4x://{0}'.format(self.course.id),
|
||||
'file://{0}'.format(self.bare_repo_dir)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegexp(GitExportError,
|
||||
str(GitExportError.CANNOT_COMMIT)):
|
||||
git_export_utils.export_to_git(
|
||||
self.course.id, 'file://{0}'.format(self.bare_repo_dir))
|
||||
99
cms/djangoapps/contentstore/tests/test_export_git.py
Normal file
99
cms/djangoapps/contentstore/tests/test_export_git.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
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 pymongo import MongoClient
|
||||
|
||||
from .utils import CourseTestCase
|
||||
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)
|
||||
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
|
||||
class TestExportGit(CourseTestCase):
|
||||
"""
|
||||
Tests pushing a course to a git repository
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup test course, user, and url.
|
||||
"""
|
||||
super(TestExportGit, self).setUp()
|
||||
self.course_module = modulestore().get_item(self.course.location)
|
||||
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
|
||||
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 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 export to git.'),
|
||||
response.content
|
||||
)
|
||||
|
||||
def test_course_export_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_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_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_utils.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)
|
||||
@@ -14,6 +14,7 @@ from .item import *
|
||||
from .import_export import *
|
||||
from .preview import *
|
||||
from .public import *
|
||||
from .export_git import *
|
||||
from .user import *
|
||||
from .tabs import *
|
||||
from .transcripts_ajax import *
|
||||
|
||||
54
cms/djangoapps/contentstore/views/export_git.py
Normal file
54
cms/djangoapps/contentstore/views/export_git.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
This views handles exporting the course xml to a git repository if
|
||||
the giturl attribute is set.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
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 .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
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def export_git(request, org, course, name):
|
||||
"""
|
||||
This method serves up the 'Export to Git' page
|
||||
"""
|
||||
location = Location('i4x', org, course, 'course', name)
|
||||
if not has_course_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
course_module = modulestore().get_item(location)
|
||||
failed = False
|
||||
|
||||
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_utils.export_to_git(
|
||||
course_module.id,
|
||||
course_module.giturl,
|
||||
request.user,
|
||||
)
|
||||
msg = _('Course successfully exported to git repository')
|
||||
except git_export_utils.GitExportError as ex:
|
||||
failed = True
|
||||
msg = str(ex)
|
||||
|
||||
return render_to_response('export_git.html', {
|
||||
'context_course': course_module,
|
||||
'msg': msg,
|
||||
'failed': failed,
|
||||
})
|
||||
@@ -156,6 +156,9 @@ THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
|
||||
#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)
|
||||
|
||||
@@ -37,6 +37,10 @@ STATIC_ROOT = TEST_ROOT / "staticfiles"
|
||||
GITHUB_REPO_ROOT = TEST_ROOT / "data"
|
||||
COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data"
|
||||
|
||||
# For testing "push to lms"
|
||||
FEATURES['ENABLE_EXPORT_GIT'] = 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
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
@import 'views/users';
|
||||
@import 'views/checklists';
|
||||
@import 'views/textbooks';
|
||||
@import 'views/export-git';
|
||||
|
||||
// base - contexts
|
||||
@import 'contexts/ie'; // ie-specific rules (mostly for known/older bugs)
|
||||
|
||||
92
cms/static/sass/views/_export-git.scss
Normal file
92
cms/static/sass/views/_export-git.scss
Normal file
@@ -0,0 +1,92 @@
|
||||
// studio - views - export to git
|
||||
// ====================
|
||||
|
||||
.view-export-git {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
.export-git-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
|
||||
.export-git-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-export-git {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
cms/templates/export_git.html
Normal file
70
cms/templates/export_git.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<%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">${_("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>${_("Export to Git")}
|
||||
</h1>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
|
||||
<div class="introduction">
|
||||
<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="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 export to git.")}</p>
|
||||
% else:
|
||||
<ul class="list-actions">
|
||||
<li class="item-action">
|
||||
<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">${_("Export to Git")}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
% endif
|
||||
</div>
|
||||
<div class="messages">
|
||||
% if msg:
|
||||
% if failed:
|
||||
<h3 class="error-text">${_('Export Failed')}:</h3>
|
||||
% else:
|
||||
<h3>${_('Export Succeeded')}:</h3>
|
||||
% endif
|
||||
<pre>${msg|h}</pre>
|
||||
% endif
|
||||
</div>
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -104,6 +104,11 @@
|
||||
<li class="nav-item nav-course-tools-export">
|
||||
<a href="${export_url}">${_("Export")}</a>
|
||||
</li>
|
||||
% 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,11 @@ urlpatterns += patterns('',
|
||||
url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict),
|
||||
)
|
||||
|
||||
|
||||
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('',
|
||||
url(r'^status/', include('service_status.urls')),
|
||||
|
||||
Reference in New Issue
Block a user