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
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.
|
||||
|
||||
221
cms/djangoapps/contentstore/management/commands/git_export.py
Normal file
221
cms/djangoapps/contentstore/management/commands/git_export.py
Normal file
@@ -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 <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:
|
||||
export_to_git(
|
||||
args[0],
|
||||
args[1],
|
||||
options.get('user', ''),
|
||||
options.get('rdir', None)
|
||||
)
|
||||
except GitExportError as ex:
|
||||
raise CommandError(str(ex))
|
||||
@@ -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))
|
||||
94
cms/djangoapps/contentstore/tests/test_push_to_lms.py
Normal file
94
cms/djangoapps/contentstore/tests/test_push_to_lms.py
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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_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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
93
cms/static/sass/views/_push.scss
Normal file
93
cms/static/sass/views/_push.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<%block name="bodyclass">is-signedin course tools export</%block>
|
||||
<%block name="bodyclass">is-signedin course tools view-push</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="wrapper-mast wrapper">
|
||||
@@ -16,41 +18,51 @@
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<div class="wrapper-master wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
|
||||
<article class="export-overview">
|
||||
<div class="description">
|
||||
<h2>${_("About Push to LMS")}</h2>
|
||||
<p>${_("Use this to export your course to its github site.")}</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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<li>${_("Your course:")} <font color="green">${context_course.id}</font></li>
|
||||
<li>${_("Course github site:")} <font color="blue">${context_course.lms.giturl}</font></li>
|
||||
% if msg:
|
||||
<li>msg: <pre>${msg|n}</pre></li>
|
||||
% endif
|
||||
<div class="push-controls">
|
||||
<h2 class="title">${_("Push Course:")}</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>
|
||||
% 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">
|
||||
<i class="icon-download"></i>
|
||||
<span class="copy">${_("Push to LMS")}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
% endif
|
||||
</div>
|
||||
|
||||
<!-- default state -->
|
||||
<div class="export-form-wrapper">
|
||||
<form action="${reverse('push_to_lms', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" method="post" enctype="multipart/form-data" class="export-form">
|
||||
<h2>${_("Push Course:")}</h2>
|
||||
|
||||
% if not context_course.lms.giturl:
|
||||
<p style="color:red">${_("giturl must be defined in your course settings before you can push to LMS")}</p>
|
||||
% else:
|
||||
<a href="${reverse('push_to_lms', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}?action=push" class="button-export">${_("Push to LMS")}</a>
|
||||
% endif
|
||||
|
||||
</form>
|
||||
</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>
|
||||
</div>
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<dl class='push-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,11 +104,11 @@
|
||||
<li class="nav-item nav-course-tools-export">
|
||||
<a href="${export_url}">${_("Export")}</a>
|
||||
</li>
|
||||
% if settings.MITX_FEATURES.get('ENABLE_PUSH_TO_LMS') and 'allow_push_to_lms' in context_course.advanced_modules:
|
||||
<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>
|
||||
</li>
|
||||
% endif
|
||||
% 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>
|
||||
</li>
|
||||
% endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,13 +99,10 @@ 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'),)
|
||||
'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')),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
#
|
||||
# python script to export course from CMS to github
|
||||
#
|
||||
# Usage: python cms_export_to_github <course_location> <username> <github_ssl_url> [<repo_dir>]
|
||||
#
|
||||
|
||||
import os, sys, string, re
|
||||
import datetime
|
||||
|
||||
DIR = "/mnt/data_export"
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# usage
|
||||
|
||||
def usage():
|
||||
print "Usage: python cms_export_to_github <course_location> <username> <github_ssl_url> [<repo_dir>]"
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd ~/mitx_all
|
||||
source STARTUP
|
||||
cd edx-platform
|
||||
|
||||
python scripts/cms_export_to_github $*
|
||||
Reference in New Issue
Block a user