""" This module creates a sysadmin dashboard for managing and viewing courses. """ import json import logging import os import subprocess import mongoengine import unicodecsv as csv from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db import IntegrityError from django.http import Http404, HttpResponse 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.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import condition from django.views.generic.base import TemplateView from opaque_keys.edx.keys import CourseKey from path import Path as path from six import StringIO, text_type import dashboard.git_import as git_import import track.views from dashboard.git_import import GitImportError from dashboard.models import CourseImportLog from edxmako.shortcuts import render_to_response from lms.djangoapps.courseware.courses import get_course_by_id from openedx.core.djangolib.markup import HTML from student.models import CourseEnrollment, Registration, UserProfile from student.roles import CourseInstructorRole, CourseStaffRole from xmodule.modulestore.django import modulestore 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.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.""" return self.def_ms.get_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() 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(), content_type='text/csv') response['Content-Disposition'] = u'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 create_user(self, uname, name, password=None): """ Creates a user """ if not uname: return _('Must provide username') if not name: return _('Must provide full name') msg = u'' if not password: return _('Password must be supplied') 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 += _(u'Oops, failed to create user {user}, {error}').format( user=user, error="IntegrityError" ) return msg reg = Registration() reg.register(user) profile = UserProfile(user=user) profile.name = name profile.save() msg += _(u'User {user} created successfully!').format(user=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 as err: msg = _(u'Cannot find user with email address {email_addr}').format(email_addr=uname) return msg else: try: user = User.objects.get(username=uname) except User.DoesNotExist as err: msg = _(u'Cannot find user with username {username} - {error}').format( username=uname, error=str(err) ) return msg user.delete() return _(u'Deleted user {username}').format(username=uname) def make_datatable(self): """ Build the datatable for this view """ datatable = { 'header': [ _('Statistic'), _('Value'), ], 'title': _('Site statistics'), 'data': [ [ _('Total number of users'), User.objects.all().count(), ], ], } return datatable def get(self, request): if not request.user.is_staff: raise Http404 context = { 'datatable': self.make_datatable(), 'msg': self.msg, 'djangopid': os.getpid(), 'modeflag': {'users': 'active-section'}, } return render_to_response(self.template_name, context) def post(self, request): """Handle various actions available on page""" if not request.user.is_staff: raise Http404 action = request.POST.get('action', '') track.views.server_track(request, action, {}, page='user_sysdashboard') if action == 'download_users': header = [_('username'), _('email'), ] data = ([u.username, u.email] for u in (User.objects.all().iterator())) return self.return_csv('users_{0}.csv'.format( request.META['SERVER_NAME']), header, data) 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 = HTML(u'

{0}

{1}


{2}').format( _('Create User Results'), self.create_user(uname, name, password), self.msg) elif action == 'del_user': uname = request.POST.get('student_uname', '').strip() self.msg = HTML(u'

{0}

{1}


{2}').format( _('Delete User Results'), self.delete_user(uname), self.msg) context = { 'datatable': self.make_datatable(), 'msg': self.msg, 'djangopid': os.getpid(), 'modeflag': {'users': 'active-section'}, } return render_to_response(self.template_name, context) class Courses(SysadminDashboardView): """ This manages adding/updating courses from git, deleting courses, and provides course listing information. """ def git_info_for_course(self, cdir): """This pulls out some git info like the last commit""" cmd = '' gdir = settings.DATA_DIR / cdir info = ['', '', ''] # Try the data dir, then try to find it in the git import dir if not gdir.exists(): git_repo_dir = getattr(settings, 'GIT_REPO_DIR', git_import.DEFAULT_GIT_REPO_DIR) gdir = path(git_repo_dir) / cdir if not gdir.exists(): return info cmd = ['git', 'log', '-1', u'--format=format:{ "commit": "%H", "author": "%an %ae", "date": "%ad"}', ] try: output_json = json.loads(subprocess.check_output(cmd, cwd=gdir).decode('utf-8')) info = [output_json['commit'], output_json['date'], output_json['author'], ] except OSError as error: log.warning(text_type(u"Error fetching git data: %s - %s"), text_type(cdir), text_type(error)) except (ValueError, subprocess.CalledProcessError): pass return info def get_course_from_git(self, gitloc, branch): """This downloads and runs the checks for importing a course in git""" if not (gitloc.endswith('.git') or gitloc.startswith('http:') or gitloc.startswith('https:') or gitloc.startswith('git:')): return _("The git repo location should end with '.git', " "and be a valid url") return self.import_mongo_course(gitloc, branch) def import_mongo_course(self, gitloc, branch): """ Imports course using management command and captures logging output at debug level for display in template """ msg = u'' log.debug(u'Adding course using git repo %s', gitloc) # Grab logging output for debugging imports output = StringIO() import_log_handler = logging.StreamHandler(output) import_log_handler.setLevel(logging.DEBUG) logger_names = ['xmodule.modulestore.xml_importer', 'dashboard.git_import', 'xmodule.modulestore.xml', 'xmodule.seq_module', ] loggers = [] for logger_name in logger_names: logger = logging.getLogger(logger_name) logger.setLevel(logging.DEBUG) logger.addHandler(import_log_handler) loggers.append(logger) error_msg = '' try: git_import.add_repo(gitloc, None, branch) except GitImportError as ex: error_msg = str(ex) ret = output.getvalue() # Remove handler hijacks for logger in loggers: logger.setLevel(logging.NOTSET) logger.removeHandler(import_log_handler) if error_msg: msg_header = error_msg color = 'red' else: msg_header = _('Added Course') color = 'blue' msg = HTML(u"

{1}

").format(color, msg_header) msg += HTML(u"
{0}
").format(escape(ret)) return msg def make_datatable(self, courses=None): """Creates course information datatable""" data = [] courses = courses or self.get_courses() for course in courses: gdir = course.id.course data.append([course.display_name, text_type(course.id)] + self.git_info_for_course(gdir)) return dict(header=[_('Course Name'), _('Directory/ID'), # Translators: "Git Commit" is a computer command; see http://gitref.org/basic/#commit _('Git Commit'), _('Last Change'), _('Last Editor')], title=_('Information about all courses'), data=data) def get(self, request): """Displays forms and course information""" if not request.user.is_staff: raise Http404 context = { 'datatable': self.make_datatable(), 'msg': self.msg, 'djangopid': os.getpid(), 'modeflag': {'courses': 'active-section'}, } return render_to_response(self.template_name, context) def post(self, request): """Handle all actions from courses view""" if not request.user.is_staff: raise Http404 action = request.POST.get('action', '') track.views.server_track(request, action, {}, page='courses_sysdashboard') courses = {course.id: course for course in self.get_courses()} if action == 'add_course': gitloc = request.POST.get('repo_location', '').strip().replace(' ', '').replace(';', '') branch = request.POST.get('repo_branch', '').strip().replace(' ', '').replace(';', '') self.msg += self.get_course_from_git(gitloc, branch) elif action == 'del_course': course_id = request.POST.get('course_id', '').strip() course_key = CourseKey.from_string(course_id) course_found = False if course_key in courses: course_found = True course = courses[course_key] else: try: course = get_course_by_id(course_key) course_found = True except Exception as err: # pylint: disable=broad-except self.msg += _( HTML(u'Error - cannot get course with ID {0}
{1}
') ).format( course_key, escape(str(err)) ) if course_found: # delete course that is stored with mongodb backend self.def_ms.delete_course(course.id, request.user.id) # don't delete user permission groups, though self.msg += \ HTML(u"{0} {1} = {2} ({3})").format( _('Deleted'), text_type(course.location), text_type(course.id), course.display_name) context = { 'datatable': self.make_datatable(list(courses.values())), 'msg': self.msg, 'djangopid': os.getpid(), 'modeflag': {'courses': 'active-section'}, } 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 = [] for course in self.get_courses(): datum = [course.display_name, course.id] datum += [CourseEnrollment.objects.filter( course_id=course.id).count()] datum += [CourseStaffRole(course.id).users_with_role().count()] datum += [','.join([x.username for x in CourseInstructorRole( course.id).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'}, } 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, ] for course in self.get_courses(): for role in roles: for user in role(course.id).users_with_role(): datum = [course.id, role, user.username, user.email, user.profile.name.encode('utf-8')] 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') if course_id: course_id = CourseKey.from_string(course_id) page_size = 10 # 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: log.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.order_by('-created') else: # Allow only course team, instructors, and staff if not (request.user.is_staff or CourseInstructorRole(course_id).has_user(request.user) or CourseStaffRole(course_id).has_user(request.user)): raise Http404 log.debug('course_id=%s', course_id) cilset = CourseImportLog.objects.filter( course_id=course_id ).order_by('-created') log.debug(u'cilset length=%s', len(cilset)) # Paginate the query set paginator = Paginator(cilset, page_size) try: logs = paginator.page(request.GET.get('page')) except PageNotAnInteger: logs = paginator.page(1) except EmptyPage: # If the page is too high or low given_page = int(request.GET.get('page')) page = min(max(1, given_page), paginator.num_pages) logs = paginator.page(page) mdb.close() context = { 'logs': logs, 'course_id': text_type(course_id) if course_id else None, 'error_msg': error_msg, 'page_size': page_size } return render_to_response(self.template_name, context)