565 lines
20 KiB
Python
565 lines
20 KiB
Python
"""
|
|
This module creates a sysadmin dashboard for managing and viewing
|
|
courses.
|
|
"""
|
|
from __future__ import absolute_import
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from six import StringIO
|
|
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 text_type
|
|
|
|
import dashboard.git_import as git_import
|
|
import track.views
|
|
from lms.djangoapps.courseware.courses import get_course_by_id
|
|
from dashboard.git_import import GitImportError
|
|
from dashboard.models import CourseImportLog
|
|
from edxmako.shortcuts import render_to_response
|
|
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'<h4>{0}</h4><p>{1}</p><hr />{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'<h4>{0}</h4><p>{1}</p><hr />{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"<h4 style='color:{0}'>{1}</h4>").format(color, msg_header)
|
|
msg += HTML(u"<pre>{0}</pre>").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}<br/><pre>{1}</pre>')
|
|
).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"<font color='red'>{0} {1} = {2} ({3})</font>").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.disconnect()
|
|
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)
|