diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index e0d58b32f0..2e16bf1258 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -31,9 +31,16 @@ class Command(BaseCommand): course_dirs = args[1:] else: course_dirs = None - print("Importing. Data_dir={data}, course_dirs={courses}".format( + self.stdout.write("Importing. Data_dir={data}, course_dirs={courses}\n".format( data=data_dir, courses=course_dirs, dis=do_import_static)) - import_from_xml(modulestore('direct'), data_dir, course_dirs, load_error_modules=False, + try: + mstore = modulestore('direct') + except KeyError: + self.stdout.write('Unable to load direct modulestore, trying ' + 'default\n') + mstore = modulestore('default') + + import_from_xml(mstore, data_dir, course_dirs, load_error_modules=False, static_content_store=contentstore(), verbose=True, do_import_static=do_import_static) diff --git a/lms/djangoapps/courseware/management/commands/import.py b/lms/djangoapps/courseware/management/commands/import.py new file mode 120000 index 0000000000..36b7e3c6fc --- /dev/null +++ b/lms/djangoapps/courseware/management/commands/import.py @@ -0,0 +1 @@ +../../../../../cms/djangoapps/contentstore/management/commands/import.py \ No newline at end of file diff --git a/lms/djangoapps/dashboard/management/__init__.py b/lms/djangoapps/dashboard/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/dashboard/management/commands/__init__.py b/lms/djangoapps/dashboard/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/dashboard/management/commands/git_add_course.py b/lms/djangoapps/dashboard/management/commands/git_add_course.py new file mode 100644 index 0000000000..1d095b11a6 --- /dev/null +++ b/lms/djangoapps/dashboard/management/commands/git_add_course.py @@ -0,0 +1,242 @@ +""" +Script for importing courseware from git/xml into a mongo modulestore +""" + +import os +import re +import datetime +import StringIO +import subprocess +import logging + +from django.conf import settings +from django.core import management +from django.core.management.base import BaseCommand, CommandError +from django.utils.translation import ugettext as _ +import mongoengine + +from dashboard.models import CourseImportLog +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.xml import XMLModuleStore + +log = logging.getLogger(__name__) + +GIT_REPO_DIR = getattr(settings, 'GIT_REPO_DIR', '/opt/edx/course_repos') +GIT_IMPORT_STATIC = getattr(settings, 'GIT_IMPORT_STATIC', True) + +GIT_IMPORT_NO_DIR = -1 +GIT_IMPORT_URL_BAD = -2 +GIT_IMPORT_CANNOT_PULL = -3 +GIT_IMPORT_XML_IMPORT_FAILED = -4 +GIT_IMPORT_UNSUPPORTED_STORE = -5 +GIT_IMPORT_MONGODB_FAIL = -6 +GIT_IMPORT_BAD_REPO = -7 + + +def add_repo(repo, rdir_in): + """This will add a git repo into the mongo modulestore""" + # pylint: disable=R0915 + + # Set defaults even if it isn't defined in settings + mongo_db = { + 'host': 'localhost', + 'user': '', + 'password': '', + 'db': 'xlog', + } + + # Allow overrides + if hasattr(settings, 'MONGODB_LOG'): + for config_item in ['host', 'user', 'password', 'db', ]: + mongo_db[config_item] = settings.MONGODB_LOG.get( + config_item, mongo_db[config_item]) + + if not os.path.isdir(GIT_REPO_DIR): + log.critical(_("Path {0} doesn't exist, please create it, " + "or configure a different path with " + "GIT_REPO_DIR").format(GIT_REPO_DIR)) + return GIT_IMPORT_NO_DIR + + # pull from git + if not repo.endswith('.git') or not ( + repo.startswith('http:') or + repo.startswith('https:') or + repo.startswith('git:') or + repo.startswith('file:')): + + log.error(_('Oops, not a git ssh url?')) + log.error(_('Expecting something like ' + 'git@github.com:mitocw/edx4edx_lite.git')) + return GIT_IMPORT_URL_BAD + + if rdir_in: + rdir = rdir_in + rdir = os.path.basename(rdir) + else: + rdir = repo.rsplit('/', 1)[-1].rsplit('.git', 1)[0] + + log.debug('rdir = {0}'.format(rdir)) + + rdirp = '{0}/{1}'.format(GIT_REPO_DIR, rdir) + if os.path.exists(rdirp): + log.info(_('directory already exists, doing a git pull instead ' + 'of git clone')) + cmd = ['git', 'pull', ] + cwd = '{0}/{1}'.format(GIT_REPO_DIR, rdir) + else: + cmd = ['git', 'clone', repo, ] + cwd = GIT_REPO_DIR + + log.debug(cmd) + cwd = os.path.abspath(cwd) + try: + ret_git = subprocess.check_output(cmd, cwd=cwd) + except subprocess.CalledProcessError: + log.exception(_('git clone or pull failed!')) + return GIT_IMPORT_CANNOT_PULL + log.debug(ret_git) + + # get commit id + cmd = ['git', 'log', '-1', '--format=%H', ] + try: + commit_id = subprocess.check_output(cmd, cwd=rdirp) + except subprocess.CalledProcessError: + log.exception(_('Unable to get git log')) + return GIT_IMPORT_BAD_REPO + + ret_git += _('\nCommit ID: {0}').format(commit_id) + + # get branch + cmd = ['git', 'rev-parse', '--abbrev-ref', 'HEAD', ] + try: + branch = subprocess.check_output(cmd, cwd=rdirp) + except subprocess.CalledProcessError: + log.exception(_('Unable to get branch info')) + return GIT_IMPORT_BAD_REPO + + ret_git += ' \nBranch: {0}'.format(branch) + + # Get XML logging logger and capture debug to parse results + output = StringIO.StringIO() + import_log_handler = logging.StreamHandler(output) + import_log_handler.setLevel(logging.DEBUG) + + logger_names = ['xmodule.modulestore.xml_importer', 'git_add_course', + 'xmodule.modulestore.xml', 'xmodule.seq_module', ] + loggers = [] + + for logger_name in logger_names: + logger = logging.getLogger(logger_name) + logger.old_level = logger.level + logger.setLevel(logging.DEBUG) + logger.addHandler(import_log_handler) + loggers.append(logger) + + try: + management.call_command('import', GIT_REPO_DIR, rdir, + nostatic=not GIT_IMPORT_STATIC) + except CommandError: + log.exception(_('Unable to run import command.')) + return GIT_IMPORT_XML_IMPORT_FAILED + except NotImplementedError: + log.exception(_('The underlying module store does not support import.')) + return GIT_IMPORT_UNSUPPORTED_STORE + + ret_import = output.getvalue() + + # Remove handler hijacks + for logger in loggers: + logger.setLevel(logger.old_level) + logger.removeHandler(import_log_handler) + + course_id = 'unknown' + location = 'unknown' + + # extract course ID from output of import-command-run and make symlink + # this is needed in order for custom course scripts to work + match = re.search('(?ms)===> IMPORTING course to location ([^ \n]+)', + ret_import) + if match: + location = match.group(1).strip() + log.debug('location = {0}'.format(location)) + course_id = location.replace('i4x://', '').replace( + '/course/', '/').split('\n')[0].strip() + + cdir = '{0}/{1}'.format(GIT_REPO_DIR, course_id.split('/')[1]) + log.debug(_('Studio course dir = {0}').format(cdir)) + + if os.path.exists(cdir) and not os.path.islink(cdir): + log.debug(_(' -> exists, but is not symlink')) + log.debug(subprocess.check_output(['ls', '-l', ], + cwd=os.path.abspath(cdir))) + try: + os.rmdir(os.path.abspath(cdir)) + except OSError: + log.exception(_('Failed to remove course directory')) + + if not os.path.exists(cdir): + log.debug(_(' -> creating symlink between {0} and {1}').format(rdirp, cdir)) + try: + os.symlink(os.path.abspath(rdirp), os.path.abspath(cdir)) + except OSError: + log.exception(_('Unable to create course symlink')) + log.debug(subprocess.check_output(['ls', '-l', ], + cwd=os.path.abspath(cdir))) + + # store import-command-run output in mongo + mongouri = 'mongodb://{user}:{password}@{host}/{db}'.format(**mongo_db) + + try: + if mongo_db['user'] and mongo_db['password']: + mdb = mongoengine.connect(mongo_db['db'], host=mongouri) + else: + mdb = mongoengine.connect(mongo_db['db'], host=mongo_db['host']) + except mongoengine.connection.ConnectionError: + log.exception(_('Unable to connect to mongodb to save log, please ' + 'check MONGODB_LOG settings')) + return GIT_IMPORT_MONGODB_FAIL + cil = CourseImportLog( + course_id=course_id, + location=location, + repo_dir=rdir, + created=datetime.datetime.now(), + import_log=ret_import, + git_log=ret_git, + ) + cil.save() + + log.debug(_('saved CourseImportLog for {0}').format(cil.course_id)) + mdb.disconnect() + return 0 + + +class Command(BaseCommand): + """ + Pull a git repo and import into the mongo based content database. + """ + + help = _('Import the specified git repository into the ' + 'modulestore and directory') + + def handle(self, *args, **options): + """Check inputs and run the command""" + + if isinstance(modulestore, XMLModuleStore): + raise CommandError(_('This script requires a mongo module store')) + + if len(args) < 1: + raise CommandError(_('This script requires at least one argument, ' + 'the git URL')) + + if len(args) > 2: + raise CommandError(_('This script requires no more than two ' + 'arguments')) + + rdir_arg = None + + if len(args) > 1: + rdir_arg = args[1] + + if add_repo(args[0], rdir_arg) != 0: + raise CommandError(_('Repo was not added, check log output ' + 'for details')) diff --git a/lms/djangoapps/dashboard/management/commands/tests/test_git_add_course.py b/lms/djangoapps/dashboard/management/commands/tests/test_git_add_course.py new file mode 100644 index 0000000000..0b3c685fbd --- /dev/null +++ b/lms/djangoapps/dashboard/management/commands/tests/test_git_add_course.py @@ -0,0 +1,104 @@ +""" +Provide tests for git_add_course management command. +""" + +import unittest +import os +import shutil +import subprocess + +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 courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +import dashboard.management.commands.git_add_course as git_add_course + +TEST_MONGODB_LOG = { + 'host': 'localhost', + 'user': '', + 'password': '', + 'db': 'test_xlog', +} + +FEATURES_WITH_SSL_AUTH = settings.FEATURES.copy() +FEATURES_WITH_SSL_AUTH['AUTH_USE_MIT_CERTIFICATES'] = True + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MONGODB_LOG=TEST_MONGODB_LOG) +@unittest.skipUnless(settings.FEATURES.get('ENABLE_SYSADMIN_DASHBOARD'), + "ENABLE_SYSADMIN_DASHBOARD not set") +class TestGitAddCourse(ModuleStoreTestCase): + """ + Tests the git_add_course management command for proper functions. + """ + + TEST_REPO = 'https://github.com/mitocw/edx4edx_lite.git' + + def assertCommandFailureRegexp(self, regex, *args): + """ + Convenience function for testing command failures + """ + with self.assertRaises(SystemExit): + self.assertRaisesRegexp(CommandError, regex, + call_command('git_add_course', *args)) + + def test_command_args(self): + """ + Validate argument checking + """ + self.assertCommandFailureRegexp( + 'This script requires at least one argument, the git URL') + self.assertCommandFailureRegexp( + 'This script requires no more than two arguments', + 'blah', 'blah', 'blah') + self.assertCommandFailureRegexp( + 'Repo was not added, check log output for details', + 'blah') + # Test successful import from command + try: + os.mkdir(getattr(settings, 'GIT_REPO_DIR')) + except OSError: + pass + + # Make a course dir that will be replaced with a symlink + # while we are at it. + if not os.path.isdir(getattr(settings, 'GIT_REPO_DIR') / 'edx4edx'): + os.mkdir(getattr(settings, 'GIT_REPO_DIR') / 'edx4edx') + + call_command('git_add_course', self.TEST_REPO, + getattr(settings, 'GIT_REPO_DIR') / 'edx4edx_lite') + if os.path.isdir(getattr(settings, 'GIT_REPO_DIR')): + shutil.rmtree(getattr(settings, 'GIT_REPO_DIR')) + + def test_add_repo(self): + """ + Various exit path tests for test_add_repo + """ + self.assertEqual(git_add_course.GIT_IMPORT_NO_DIR, + git_add_course.add_repo(self.TEST_REPO, None)) + try: + os.mkdir(getattr(settings, 'GIT_REPO_DIR')) + except OSError: + pass + self.assertEqual(git_add_course.GIT_IMPORT_URL_BAD, + git_add_course.add_repo('foo', None)) + + self.assertEqual( + git_add_course.GIT_IMPORT_CANNOT_PULL, + git_add_course.add_repo('file:///foobar.git', None) + ) + + # Test git repo that exists, but is "broken" + bare_repo = os.path.abspath('{0}/{1}'.format(settings.TEST_ROOT, 'bare.git')) + os.mkdir(os.path.abspath(bare_repo)) + subprocess.call(['git', '--bare', 'init', ], cwd=bare_repo) + + self.assertEqual( + git_add_course.GIT_IMPORT_BAD_REPO, + git_add_course.add_repo('file://{0}'.format(bare_repo), None) + ) + shutil.rmtree(bare_repo) diff --git a/lms/djangoapps/dashboard/models.py b/lms/djangoapps/dashboard/models.py index 6b20219993..096288f6d4 100644 --- a/lms/djangoapps/dashboard/models.py +++ b/lms/djangoapps/dashboard/models.py @@ -1 +1,17 @@ -# Create your models here. +"""Models for dashboard application""" + +import mongoengine + + +class CourseImportLog(mongoengine.Document): + """Mongoengine model for git log""" + # pylint: disable=R0924 + + course_id = mongoengine.StringField(max_length=128) + location = mongoengine.StringField(max_length=168) + import_log = mongoengine.StringField(max_length=20 * 65535) + git_log = mongoengine.StringField(max_length=65535) + repo_dir = mongoengine.StringField(max_length=128) + created = mongoengine.DateTimeField() + meta = {'indexes': ['course_id', 'created'], + 'allow_inheritance': False} diff --git a/lms/djangoapps/dashboard/sysadmin.py b/lms/djangoapps/dashboard/sysadmin.py new file mode 100644 index 0000000000..9627e37f0b --- /dev/null +++ b/lms/djangoapps/dashboard/sysadmin.py @@ -0,0 +1,692 @@ +""" +This module creates a sysadmin dashboard for managing and viewing +courses. +""" +import csv +import json +import logging +import os +import subprocess +import time +import StringIO +from datetime import datetime + +from django.conf import settings +from django.contrib.auth import authenticate +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied +from django.db import IntegrityError +from django.http import HttpResponse, Http404 +from django.utils.decorators import method_decorator +from django.utils.html import escape +from django.utils.translation import ugettext as _ +from django.views.decorators.cache import cache_control +from django.views.generic.base import TemplateView +from django.views.decorators.http import condition +from django_future.csrf import ensure_csrf_cookie +from edxmako.shortcuts import render_to_response +import mongoengine + +from courseware.courses import get_course_by_id +from courseware.roles import CourseStaffRole, CourseInstructorRole +import dashboard.management.commands.git_add_course as git_add_course +from dashboard.models import CourseImportLog +from external_auth.models import ExternalAuthMap +from external_auth.views import generate_password +from student.models import CourseEnrollment, UserProfile, Registration +import track.views +from xmodule.contentstore.django import contentstore +from xmodule.modulestore import MONGO_MODULESTORE_TYPE +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.store_utilities import delete_course +from xmodule.modulestore.xml import XMLModuleStore + + +log = logging.getLogger(__name__) + + +class SysadminDashboardView(TemplateView): + """Base class for sysadmin dashboard views with common methods""" + + template_name = 'sysadmin_dashboard.html' + + def __init__(self, **kwargs): + """ + Initialize base sysadmin dashboard class with modulestore, + modulestore_type and return msg + """ + + self.def_ms = modulestore() + self.is_using_mongo = True + if isinstance(self.def_ms, XMLModuleStore): + self.is_using_mongo = False + self.msg = u'' + self.datatable = [] + super(SysadminDashboardView, self).__init__(**kwargs) + + @method_decorator(ensure_csrf_cookie) + @method_decorator(login_required) + @method_decorator(cache_control(no_cache=True, no_store=True, + must_revalidate=True)) + @method_decorator(condition(etag_func=None)) + def dispatch(self, *args, **kwargs): + return super(SysadminDashboardView, self).dispatch(*args, **kwargs) + + def get_courses(self): + """ Get an iterable list of courses.""" + + courses = self.def_ms.get_courses() + courses = dict([c.id, c] for c in courses) # no course directory + + return courses + + def return_csv(self, filename, header, data): + """ + Convenient function for handling the http response of a csv. + data should be iterable and is used to stream object over http + """ + + csv_file = StringIO.StringIO() + writer = csv.writer(csv_file, dialect='excel', quotechar='"', + quoting=csv.QUOTE_ALL) + + writer.writerow(header) + + # Setup streaming of the data + def read_and_flush(): + """Read and clear buffer for optimization""" + csv_file.seek(0) + csv_data = csv_file.read() + csv_file.seek(0) + csv_file.truncate() + return csv_data + + def csv_data(): + """Generator for handling potentially large CSVs""" + for row in data: + writer.writerow(row) + csv_data = read_and_flush() + yield csv_data + response = HttpResponse(csv_data(), mimetype='text/csv') + response['Content-Disposition'] = 'attachment; filename={0}'.format( + filename) + return response + + +class Users(SysadminDashboardView): + """ + The status view provides Web based user management, a listing of + courses loaded, and user statistics + """ + + def fix_external_auth_map_passwords(self): + """ + This corrects any passwords that have drifted from eamap to + internal django auth. Needs to be removed when fixed in external_auth + """ + + msg = '' + for eamap in ExternalAuthMap.objects.all(): + euser = eamap.user + epass = eamap.internal_password + if euser is None: + continue + try: + testuser = authenticate(username=euser.username, password=epass) + except (TypeError, PermissionDenied, AttributeError), err: + msg += _('Failed in authenticating {0}, error {1}\n' + ).format(euser, err) + continue + if testuser is None: + msg += _('Failed in authenticating {0}\n').format(euser) + msg += _('fixed password') + euser.set_password(epass) + euser.save() + continue + if not msg: + msg = _('All ok!') + return msg + + def create_user(self, uname, name, password=None): + """ Creates a user (both SSL and regular)""" + + if not uname: + return _('Must provide username') + if not name: + return _('Must provide full name') + + email_domain = getattr(settings, 'SSL_AUTH_EMAIL_DOMAIN', 'MIT.EDU') + + msg = u'' + if settings.FEATURES['AUTH_USE_MIT_CERTIFICATES']: + if not '@' in uname: + email = '{0}@{1}'.format(uname, email_domain) + else: + email = uname + if not email.endswith('@{0}'.format(email_domain)): + msg += u'{0} @{1}'.format(_('email must end in'), email_domain) + return msg + mit_domain = 'ssl:MIT' + if ExternalAuthMap.objects.filter(external_id=email, + external_domain=mit_domain): + msg += _('Failed - email {0} already exists as ' + 'external_id').format(email) + return msg + new_password = generate_password() + else: + if not password: + return _('Password must be supplied if not using certificates') + + email = uname + + if not '@' in email: + msg += _('email address required (not username)') + return msg + new_password = password + + user = User(username=uname, email=email, is_active=True) + user.set_password(new_password) + try: + user.save() + except IntegrityError: + msg += _('Oops, failed to create user {0}, ' + 'IntegrityError').format(user) + return msg + + reg = Registration() + reg.register(user) + + profile = UserProfile(user=user) + profile.name = name + profile.save() + + if settings.FEATURES['AUTH_USE_MIT_CERTIFICATES']: + credential_string = getattr(settings, 'SSL_AUTH_DN_FORMAT_STRING', + '/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}') + credentials = credential_string.format(name, email) + eamap = ExternalAuthMap( + external_id=email, + external_email=email, + external_domain=mit_domain, + external_name=name, + internal_password=new_password, + external_credentials=json.dumps(credentials), + ) + eamap.user = user + eamap.dtsignup = datetime.now() + eamap.save() + + msg += _('User {0} created successfully!').format(user) + return msg + + def delete_user(self, uname): + """Deletes a user from django auth""" + + if not uname: + return _('Must provide username') + if '@' in uname: + try: + user = User.objects.get(email=uname) + except User.DoesNotExist, err: + msg = _('Cannot find user with email address {0}').format(uname) + return msg + else: + try: + user = User.objects.get(username=uname) + except User.DoesNotExist, err: + msg = _('Cannot find user with username {0} - {1}' + ).format(uname, str(err)) + return msg + user.delete() + return _('Deleted user {0}').format(uname) + + def make_common_context(self): + """Returns the datatable used for this view""" + + self.datatable = {} + courses = self.get_courses() + + self.datatable = dict(header=[_('Statistic'), _('Value')], + title=_('Site statistics')) + self.datatable['data'] = [[_('Total number of users'), + User.objects.all().count()]] + + self.msg += u'
{1}{2}'.format(
+ _('Repair Results'),
+ self.fix_external_auth_map_passwords(),
+ self.msg)
+ self.datatable = {}
+ elif action == 'create_user':
+ uname = request.POST.get('student_uname', '').strip()
+ name = request.POST.get('student_fullname', '').strip()
+ password = request.POST.get('student_password', '').strip()
+ self.msg = u'{1}
{1}
{0}").format(escape(ret))
+ return msg
+
+ def import_xml_course(self, gitloc, datatable):
+ """Imports a git course into the XMLModuleStore"""
+
+ msg = u''
+ if not getattr(settings, 'GIT_IMPORT_WITH_XMLMODULESTORE', False):
+ return _('Refusing to import. GIT_IMPORT_WITH_XMLMODULESTORE is '
+ 'not turned on, and it is generally not safe to import '
+ 'into an XMLModuleStore with multithreaded. We '
+ 'recommend you enable the MongoDB based module store '
+ 'instead, unless this is a development environment.')
+ cdir = (gitloc.rsplit('/', 1)[1])[:-4]
+ gdir = settings.DATA_DIR / cdir
+ if os.path.exists(gdir):
+ msg += _("The course {0} already exists in the data directory! "
+ "(reloading anyway)").format(cdir)
+ cmd = ['git', 'pull', ]
+ cwd = gdir
+ else:
+ cmd = ['git', 'clone', gitloc, ]
+ cwd = settings.DATA_DIR
+ cwd = os.path.abspath(cwd)
+ try:
+ cmd_output = escape(subprocess.check_output(cmd, cwd=cwd))
+ except subprocess.CalledProcessError:
+ return _('Unable to clone or pull repository. Please check your url.')
+
+ msg += u'{0}'.format(cmd_output)
+ if not os.path.exists(gdir):
+ msg += _('Failed to clone repository to {0}').format(gdir)
+ return msg
+ self.def_ms.try_load_course(os.path.abspath(gdir))
+ errlog = self.def_ms.errored_courses.get(cdir, '')
+ if errlog:
+ msg += u'{0}'.format(escape(errlog))
+ else:
+ course = self.def_ms.courses[os.path.abspath(gdir)]
+ msg += _('Loaded course {0} {1}{0}: {1}{1}').format(
+ course_id, escape(str(err))
+ )
+
+ is_mongo_course = (modulestore().get_modulestore_type(course_id) == MONGO_MODULESTORE_TYPE)
+ if course_found and not is_mongo_course:
+ cdir = course.data_dir
+ self.def_ms.courses.pop(cdir)
+
+ # now move the directory (don't actually delete it)
+ new_dir = "{course_dir}_deleted_{timestamp}".format(
+ course_dir=cdir,
+ timestamp=int(time.time())
+ )
+ os.rename(settings.DATA_DIR / cdir, settings.DATA_DIR / new_dir)
+
+ self.msg += (u"Deleted "
+ u"{0} = {1} ({2})".format(
+ cdir, course.id, course.display_name))
+
+ elif course_found and is_mongo_course:
+ # delete course that is stored with mongodb backend
+ loc = course.location
+ content_store = contentstore()
+ commit = True
+ delete_course(self.def_ms, content_store, loc, commit)
+ # don't delete user permission groups, though
+ self.msg += \
+ u"{0} {1} = {2} ({3})".format(
+ _('Deleted'), loc, course.id, course.display_name)
+ datatable = self.make_datatable()
+
+ context = {
+ 'datatable': datatable,
+ 'msg': self.msg,
+ 'djangopid': os.getpid(),
+ 'modeflag': {'courses': 'active-section'},
+ 'mitx_version': getattr(settings, 'VERSION_STRING', ''),
+ }
+ return render_to_response(self.template_name, context)
+
+
+class Staffing(SysadminDashboardView):
+ """
+ The status view provides a view of staffing and enrollment in
+ courses that include an option to download the data as a csv.
+ """
+
+ def get(self, request):
+ """Displays course Enrollment and staffing course statistics"""
+
+ if not request.user.is_staff:
+ raise Http404
+ data = []
+
+ courses = self.get_courses()
+
+ for (cdir, course) in courses.items(): # pylint: disable=unused-variable
+ datum = [course.display_name, course.id]
+ datum += [CourseEnrollment.objects.filter(
+ course_id=course.id).count()]
+ datum += [CourseStaffRole(course.location).users_with_role().count()]
+ datum += [','.join([x.username for x in CourseInstructorRole(
+ course.location).users_with_role()])]
+ data.append(datum)
+
+ datatable = dict(header=[_('Course Name'), _('course_id'),
+ _('# enrolled'), _('# staff'),
+ _('instructors')],
+ title=_('Enrollment information for all courses'),
+ data=data)
+ context = {
+ 'datatable': datatable,
+ 'msg': self.msg,
+ 'djangopid': os.getpid(),
+ 'modeflag': {'staffing': 'active-section'},
+ 'mitx_version': getattr(settings, 'VERSION_STRING', ''),
+ }
+ return render_to_response(self.template_name, context)
+
+ def post(self, request):
+ """Handle all actions from staffing and enrollment view"""
+
+ action = request.POST.get('action', '')
+ track.views.server_track(request, action, {},
+ page='staffing_sysdashboard')
+
+ if action == 'get_staff_csv':
+ data = []
+ roles = [CourseInstructorRole, CourseStaffRole, ]
+
+ courses = self.get_courses()
+
+ for (cdir, course) in courses.items(): # pylint: disable=unused-variable
+ for role in roles:
+ for user in role(course.location).users_with_role():
+ datum = [course.id, role, user.username, user.email,
+ user.profile.name]
+ data.append(datum)
+ header = [_('course_id'),
+ _('role'), _('username'),
+ _('email'), _('full_name'), ]
+ return self.return_csv('staff_{0}.csv'.format(
+ request.META['SERVER_NAME']), header, data)
+
+ return self.get(request)
+
+
+class GitLogs(TemplateView):
+ """
+ This provides a view into the import of courses from git repositories.
+ It is convenient for allowing course teams to see what may be wrong with
+ their xml
+ """
+
+ template_name = 'sysadmin_dashboard_gitlogs.html'
+
+ @method_decorator(login_required)
+ def get(self, request, *args, **kwargs):
+ """Shows logs of imports that happened as a result of a git import"""
+
+ course_id = kwargs.get('course_id')
+
+ # Set mongodb defaults even if it isn't defined in settings
+ mongo_db = {
+ 'host': 'localhost',
+ 'user': '',
+ 'password': '',
+ 'db': 'xlog',
+ }
+
+ # Allow overrides
+ if hasattr(settings, 'MONGODB_LOG'):
+ for config_item in ['host', 'user', 'password', 'db', ]:
+ mongo_db[config_item] = settings.MONGODB_LOG.get(
+ config_item, mongo_db[config_item])
+
+ mongouri = 'mongodb://{user}:{password}@{host}/{db}'.format(**mongo_db)
+
+ error_msg = ''
+
+ try:
+ if mongo_db['user'] and mongo_db['password']:
+ mdb = mongoengine.connect(mongo_db['db'], host=mongouri)
+ else:
+ mdb = mongoengine.connect(mongo_db['db'], host=mongo_db['host'])
+ except mongoengine.connection.ConnectionError:
+ logging.exception(_('Unable to connect to mongodb to save log, '
+ 'please check MONGODB_LOG settings.'))
+
+ if course_id is None:
+ # Require staff if not going to specific course
+ if not request.user.is_staff:
+ raise Http404
+ cilset = CourseImportLog.objects.all().order_by('-created')
+ else:
+ try:
+ course = get_course_by_id(course_id)
+ except Exception: # pylint: disable=broad-except
+ cilset = None
+ error_msg = _('Cannot find course {0}').format(course_id)
+
+ # Allow only course team, instructors, and staff
+ if not (request.user.is_staff or
+ CourseInstructorRole(course.location).has_user(request.user) or
+ CourseStaffRole(course.location).has_user(request.user)):
+ raise Http404
+ log.debug('course_id={0}'.format(course_id))
+ cilset = CourseImportLog.objects.filter(
+ course_id=course_id).order_by('-created')
+ log.debug('cilset length={0}'.format(len(cilset)))
+ mdb.disconnect()
+ context = {'cilset': cilset,
+ 'course_id': course_id,
+ 'error_msg': error_msg}
+
+ return render_to_response(self.template_name, context)
diff --git a/lms/djangoapps/dashboard/sysadmin_urls.py b/lms/djangoapps/dashboard/sysadmin_urls.py
new file mode 100644
index 0000000000..e543537415
--- /dev/null
+++ b/lms/djangoapps/dashboard/sysadmin_urls.py
@@ -0,0 +1,18 @@
+"""
+Urls for sysadmin dashboard feature
+"""
+# pylint: disable=E1120
+
+from django.conf.urls import patterns, url
+
+from dashboard import sysadmin
+
+urlpatterns = patterns(
+ '',
+ url(r'^$', sysadmin.Users.as_view(), name="sysadmin"),
+ url(r'^courses/?$', sysadmin.Courses.as_view(), name="sysadmin_courses"),
+ url(r'^staffing/?$', sysadmin.Staffing.as_view(), name="sysadmin_staffing"),
+ url(r'^gitlogs/?$', sysadmin.GitLogs.as_view(), name="gitlogs"),
+ url(r'^gitlogs/(?P${_("Go to each individual course's Instructor dashboard to manage course enrollment.")}
+${msg}
+%endif + +%if datatable: + ++
| ${hname} | + %endfor +
|---|
| ${value} | + %endfor +
${plot['info']}
+