Merge pull request #615 from MITx/feature/ichuang/instructor-dashboard-upgrade
Upgrade to instructor dashboard
This commit is contained in:
@@ -4,7 +4,7 @@ Models for Student Information
|
||||
Replication Notes
|
||||
|
||||
In our live deployment, we intend to run in a scenario where there is a pool of
|
||||
Portal servers that hold the canoncial user information and that user
|
||||
Portal servers that hold the canoncial user information and that user
|
||||
information is replicated to slave Course server pools. Each Course has a set of
|
||||
servers that serves only its content and has users that are relevant only to it.
|
||||
|
||||
@@ -61,6 +61,7 @@ from xmodule.modulestore.django import modulestore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
"""This is where we store all the user demographic fields. We have a
|
||||
separate table for this rather than extending the built-in Django auth_user.
|
||||
@@ -175,6 +176,7 @@ class PendingEmailChange(models.Model):
|
||||
new_email = models.CharField(blank=True, max_length=255, db_index=True)
|
||||
activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)
|
||||
|
||||
|
||||
class CourseEnrollment(models.Model):
|
||||
user = models.ForeignKey(User)
|
||||
course_id = models.CharField(max_length=255, db_index=True)
|
||||
@@ -184,6 +186,10 @@ class CourseEnrollment(models.Model):
|
||||
class Meta:
|
||||
unique_together = (('user', 'course_id'), )
|
||||
|
||||
def __unicode__(self):
|
||||
return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created)
|
||||
|
||||
|
||||
@receiver(post_save, sender=CourseEnrollment)
|
||||
def assign_default_role(sender, instance, **kwargs):
|
||||
if instance.user.is_staff:
|
||||
@@ -273,6 +279,7 @@ def add_user_to_default_group(user, group):
|
||||
utg.users.add(User.objects.get(username=user))
|
||||
utg.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def update_user_information(sender, instance, created, **kwargs):
|
||||
try:
|
||||
@@ -283,6 +290,7 @@ def update_user_information(sender, instance, created, **kwargs):
|
||||
log.error(unicode(e))
|
||||
log.error("update user info to discussion failed for user with id: " + str(instance.id))
|
||||
|
||||
|
||||
########################## REPLICATION SIGNALS #################################
|
||||
# @receiver(post_save, sender=User)
|
||||
def replicate_user_save(sender, **kwargs):
|
||||
@@ -292,6 +300,7 @@ def replicate_user_save(sender, **kwargs):
|
||||
for course_db_name in db_names_to_replicate_to(user_obj.id):
|
||||
replicate_user(user_obj, course_db_name)
|
||||
|
||||
|
||||
# @receiver(post_save, sender=CourseEnrollment)
|
||||
def replicate_enrollment_save(sender, **kwargs):
|
||||
"""This is called when a Student enrolls in a course. It has to do the
|
||||
@@ -317,12 +326,14 @@ def replicate_enrollment_save(sender, **kwargs):
|
||||
log.debug("Replicating user profile because of new enrollment")
|
||||
user_profile = UserProfile.objects.get(user_id=enrollment_obj.user_id)
|
||||
replicate_model(UserProfile.save, user_profile, enrollment_obj.user_id)
|
||||
|
||||
|
||||
|
||||
# @receiver(post_delete, sender=CourseEnrollment)
|
||||
def replicate_enrollment_delete(sender, **kwargs):
|
||||
enrollment_obj = kwargs['instance']
|
||||
return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id)
|
||||
|
||||
|
||||
|
||||
# @receiver(post_save, sender=UserProfile)
|
||||
def replicate_userprofile_save(sender, **kwargs):
|
||||
"""We just updated the UserProfile (say an update to the name), so push that
|
||||
@@ -330,12 +341,13 @@ def replicate_userprofile_save(sender, **kwargs):
|
||||
user_profile_obj = kwargs['instance']
|
||||
return replicate_model(UserProfile.save, user_profile_obj, user_profile_obj.user_id)
|
||||
|
||||
|
||||
|
||||
######### Replication functions #########
|
||||
USER_FIELDS_TO_COPY = ["id", "username", "first_name", "last_name", "email",
|
||||
"password", "is_staff", "is_active", "is_superuser",
|
||||
"last_login", "date_joined"]
|
||||
|
||||
|
||||
def replicate_user(portal_user, course_db_name):
|
||||
"""Replicate a User to the correct Course DB. This is more complicated than
|
||||
it should be because Askbot extends the auth_user table and adds its own
|
||||
@@ -359,9 +371,10 @@ def replicate_user(portal_user, course_db_name):
|
||||
course_user.save(using=course_db_name)
|
||||
unmark(course_user)
|
||||
|
||||
|
||||
def replicate_model(model_method, instance, user_id):
|
||||
"""
|
||||
model_method is the model action that we want replicated. For instance,
|
||||
model_method is the model action that we want replicated. For instance,
|
||||
UserProfile.save
|
||||
"""
|
||||
if not should_replicate(instance):
|
||||
@@ -376,8 +389,10 @@ def replicate_model(model_method, instance, user_id):
|
||||
model_method(instance, using=db_name)
|
||||
unmark(instance)
|
||||
|
||||
|
||||
######### Replication Helpers #########
|
||||
|
||||
|
||||
def is_valid_course_id(course_id):
|
||||
"""Right now, the only database that's not a course database is 'default'.
|
||||
I had nicer checking in here originally -- it would scan the courses that
|
||||
@@ -387,26 +402,30 @@ def is_valid_course_id(course_id):
|
||||
"""
|
||||
return course_id != 'default'
|
||||
|
||||
|
||||
def is_portal():
|
||||
"""Are we in the portal pool? Only Portal servers are allowed to replicate
|
||||
their changes. For now, only Portal servers see multiple DBs, so we use
|
||||
that to decide."""
|
||||
return len(settings.DATABASES) > 1
|
||||
|
||||
|
||||
def db_names_to_replicate_to(user_id):
|
||||
"""Return a list of DB names that this user_id is enrolled in."""
|
||||
return [c.course_id
|
||||
for c in CourseEnrollment.objects.filter(user_id=user_id)
|
||||
if is_valid_course_id(c.course_id)]
|
||||
|
||||
|
||||
def marked_handled(instance):
|
||||
"""Have we marked this instance as being handled to avoid infinite loops
|
||||
caused by saving models in post_save hooks for the same models?"""
|
||||
return hasattr(instance, '_do_not_copy_to_course_db') and instance._do_not_copy_to_course_db
|
||||
|
||||
|
||||
def mark_handled(instance):
|
||||
"""You have to mark your instance with this function or else we'll go into
|
||||
an infinite loop since we're putting listeners on Model saves/deletes and
|
||||
an infinite loop since we're putting listeners on Model saves/deletes and
|
||||
the act of replication requires us to call the same model method.
|
||||
|
||||
We create a _replicated attribute to differentiate the first save of this
|
||||
@@ -415,16 +434,18 @@ def mark_handled(instance):
|
||||
"""
|
||||
instance._do_not_copy_to_course_db = True
|
||||
|
||||
|
||||
def unmark(instance):
|
||||
"""If we don't unmark a model after we do replication, then consecutive
|
||||
"""If we don't unmark a model after we do replication, then consecutive
|
||||
save() calls won't be properly replicated."""
|
||||
instance._do_not_copy_to_course_db = False
|
||||
|
||||
|
||||
def should_replicate(instance):
|
||||
"""Should this instance be replicated? We need to be a Portal server and
|
||||
the instance has to not have been marked_handled."""
|
||||
if marked_handled(instance):
|
||||
# Basically, avoid an infinite loop. You should
|
||||
# Basically, avoid an infinite loop. You should
|
||||
log.debug("{0} should not be replicated because it's been marked"
|
||||
.format(instance))
|
||||
return False
|
||||
|
||||
@@ -75,8 +75,11 @@ def index(request, extra_context={}, user=None):
|
||||
entry.summary = soup.getText()
|
||||
|
||||
# The course selection work is done in courseware.courses.
|
||||
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
|
||||
if not domain:
|
||||
domain = request.META.get('HTTP_HOST')
|
||||
universities = get_courses_by_university(None,
|
||||
domain=request.META.get('HTTP_HOST'))
|
||||
domain=domain)
|
||||
context = {'universities': universities, 'entries': entries}
|
||||
context.update(extra_context)
|
||||
return render_to_response('index.html', context)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from functools import wraps
|
||||
@@ -75,7 +76,7 @@ def grade_histogram(module_id):
|
||||
|
||||
grades = list(cursor.fetchall())
|
||||
grades.sort(key=lambda x: x[0]) # Add ORDER BY to sql query?
|
||||
if len(grades) == 1 and grades[0][0] is None:
|
||||
if len(grades) >= 1 and grades[0][0] is None:
|
||||
return []
|
||||
return grades
|
||||
|
||||
@@ -117,6 +118,14 @@ def add_histogram(get_html, module, user):
|
||||
data_dir = ""
|
||||
source_file = module.metadata.get('source_file','') # source used to generate the problem XML, eg latex or word
|
||||
|
||||
# useful to indicate to staff if problem has been released or not
|
||||
# TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here
|
||||
now = time.gmtime()
|
||||
is_released = "unknown"
|
||||
mstart = getattr(module.descriptor,'start')
|
||||
if mstart is not None:
|
||||
is_released = "<font color='red'>Yes!</font>" if (now > mstart) else "<font color='green'>Not yet</font>"
|
||||
|
||||
staff_context = {'definition': module.definition.get('data'),
|
||||
'metadata': json.dumps(module.metadata, indent=4),
|
||||
'location': module.location,
|
||||
@@ -130,7 +139,9 @@ def add_histogram(get_html, module, user):
|
||||
'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa'),
|
||||
'histogram': json.dumps(histogram),
|
||||
'render_histogram': render_histogram,
|
||||
'module_content': get_html()}
|
||||
'module_content': get_html(),
|
||||
'is_released': is_released,
|
||||
}
|
||||
return render_to_string("staff_problem_info.html", staff_context)
|
||||
|
||||
return _get_html
|
||||
|
||||
@@ -35,6 +35,11 @@ def make_error_tracker():
|
||||
if in_exception_handler():
|
||||
exc_str = exc_info_to_str(sys.exc_info())
|
||||
|
||||
# don't display irrelevant gunicorn sync error
|
||||
if (('python2.7/site-packages/gunicorn/workers/sync.py' in exc_str) and
|
||||
('[Errno 11] Resource temporarily unavailable' in exc_str)):
|
||||
exc_str = ''
|
||||
|
||||
errors.append((msg, exc_str))
|
||||
|
||||
return ErrorLog(error_tracker, errors)
|
||||
|
||||
@@ -12,6 +12,9 @@ import sys
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
|
||||
remove_comments=True, remove_blank_text=True)
|
||||
|
||||
def name_to_pathname(name):
|
||||
"""
|
||||
Convert a location name for use in a path: replace ':' with '/'.
|
||||
@@ -150,7 +153,7 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
|
||||
Returns an lxml Element
|
||||
"""
|
||||
return etree.parse(file_object).getroot()
|
||||
return etree.parse(file_object, parser=edx_xml_parser).getroot()
|
||||
|
||||
@classmethod
|
||||
def load_file(cls, filepath, fs, location):
|
||||
|
||||
@@ -94,3 +94,8 @@ course content can be setup to trigger an automatic reload when changes are push
|
||||
|
||||
The mitx server will then do "git reset --hard HEAD; git clean -f -d; git pull origin" in that directory. After the pull,
|
||||
it will reload the modulestore for that course.
|
||||
|
||||
Note that the gitreload-based workflow is not meant for deployments on AWS (or elsewhere) which use collectstatic, since collectstatic is not run by a gitreload event.
|
||||
|
||||
Also, the gitreload feature needs MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True in the django settings.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ def has_access(user, obj, action):
|
||||
Things this module understands:
|
||||
- start dates for modules
|
||||
- DISABLE_START_DATES
|
||||
- different access for staff, course staff, and students.
|
||||
- different access for instructor, staff, course staff, and students.
|
||||
|
||||
user: a Django user object. May be anonymous.
|
||||
|
||||
@@ -70,6 +70,20 @@ def has_access(user, obj, action):
|
||||
raise TypeError("Unknown object type in has_access(): '{0}'"
|
||||
.format(type(obj)))
|
||||
|
||||
def get_access_group_name(obj,action):
|
||||
'''
|
||||
Returns group name for user group which has "action" access to the given object.
|
||||
|
||||
Used in managing access lists.
|
||||
'''
|
||||
|
||||
if isinstance(obj, CourseDescriptor):
|
||||
return _get_access_group_name_course_desc(obj, action)
|
||||
|
||||
# Passing an unknown object here is a coding error, so rather than
|
||||
# returning a default, complain.
|
||||
raise TypeError("Unknown object type in get_access_group_name(): '{0}'"
|
||||
.format(type(obj)))
|
||||
|
||||
# ================ Implementation helpers ================================
|
||||
|
||||
@@ -138,11 +152,19 @@ def _has_access_course_desc(user, course, action):
|
||||
'load': can_load,
|
||||
'enroll': can_enroll,
|
||||
'see_exists': see_exists,
|
||||
'staff': lambda: _has_staff_access_to_descriptor(user, course)
|
||||
'staff': lambda: _has_staff_access_to_descriptor(user, course),
|
||||
'instructor': lambda: _has_instructor_access_to_descriptor(user, course),
|
||||
}
|
||||
|
||||
return _dispatch(checkers, action, user, course)
|
||||
|
||||
def _get_access_group_name_course_desc(course, action):
|
||||
'''
|
||||
Return name of group which gives staff access to course. Only understands action = 'staff'
|
||||
'''
|
||||
if not action=='staff':
|
||||
return []
|
||||
return _course_staff_group_name(course.location)
|
||||
|
||||
def _has_access_error_desc(user, descriptor, action):
|
||||
"""
|
||||
@@ -292,6 +314,17 @@ def _course_staff_group_name(location):
|
||||
"""
|
||||
return 'staff_%s' % Location(location).course
|
||||
|
||||
|
||||
def _course_instructor_group_name(location):
|
||||
"""
|
||||
Get the name of the instructor group for a location. Right now, that's instructor_COURSE.
|
||||
A course instructor has all staff privileges, but also can manage list of course staff (add, remove, list).
|
||||
|
||||
location: something that can passed to Location.
|
||||
"""
|
||||
return 'instructor_%s' % Location(location).course
|
||||
|
||||
|
||||
def _has_global_staff_access(user):
|
||||
if user.is_staff:
|
||||
debug("Allow: user.is_staff")
|
||||
@@ -301,17 +334,28 @@ def _has_global_staff_access(user):
|
||||
return False
|
||||
|
||||
|
||||
def _has_staff_access_to_location(user, location):
|
||||
'''
|
||||
Returns True if the given user has staff access to a location. For now this
|
||||
is equivalent to having staff access to the course location.course.
|
||||
def _has_instructor_access_to_location(user, location):
|
||||
return _has_access_to_location(user, location, 'instructor')
|
||||
|
||||
This means that user is in the staff_* group, or is an overall admin.
|
||||
|
||||
def _has_staff_access_to_location(user, location):
|
||||
return _has_access_to_location(user, location, 'staff')
|
||||
|
||||
|
||||
def _has_access_to_location(user, location, access_level):
|
||||
'''
|
||||
Returns True if the given user has access_level (= staff or
|
||||
instructor) access to a location. For now this is equivalent to
|
||||
having staff / instructor access to the course location.course.
|
||||
|
||||
This means that user is in the staff_* group or instructor_* group, or is an overall admin.
|
||||
|
||||
TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course
|
||||
(e.g. staff in 2012 is different from 2013, but maybe some people always have access)
|
||||
|
||||
course is a string: the course field of the location being accessed.
|
||||
location = location
|
||||
access_level = string, either "staff" or "instructor"
|
||||
'''
|
||||
if user is None or (not user.is_authenticated()):
|
||||
debug("Deny: no user or anon user")
|
||||
@@ -322,24 +366,46 @@ def _has_staff_access_to_location(user, location):
|
||||
|
||||
# If not global staff, is the user in the Auth group for this class?
|
||||
user_groups = [g.name for g in user.groups.all()]
|
||||
staff_group = _course_staff_group_name(location)
|
||||
if staff_group in user_groups:
|
||||
debug("Allow: user in group %s", staff_group)
|
||||
return True
|
||||
debug("Deny: user not in group %s", staff_group)
|
||||
|
||||
if access_level == 'staff':
|
||||
staff_group = _course_staff_group_name(location)
|
||||
if staff_group in user_groups:
|
||||
debug("Allow: user in group %s", staff_group)
|
||||
return True
|
||||
debug("Deny: user not in group %s", staff_group)
|
||||
|
||||
if access_level == 'instructor' or access_level == 'staff': # instructors get staff privileges
|
||||
instructor_group = _course_instructor_group_name(location)
|
||||
if instructor_group in user_groups:
|
||||
debug("Allow: user in group %s", instructor_group)
|
||||
return True
|
||||
debug("Deny: user not in group %s", instructor_group)
|
||||
|
||||
else:
|
||||
log.debug("Error in access._has_access_to_location access_level=%s unknown" % access_level)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _has_staff_access_to_course_id(user, course_id):
|
||||
"""Helper method that takes a course_id instead of a course name"""
|
||||
loc = CourseDescriptor.id_to_location(course_id)
|
||||
return _has_staff_access_to_location(user, loc)
|
||||
|
||||
|
||||
def _has_instructor_access_to_descriptor(user, descriptor):
|
||||
"""Helper method that checks whether the user has staff access to
|
||||
the course of the location.
|
||||
|
||||
descriptor: something that has a location attribute
|
||||
"""
|
||||
return _has_instructor_access_to_location(user, descriptor.location)
|
||||
|
||||
def _has_staff_access_to_descriptor(user, descriptor):
|
||||
"""Helper method that checks whether the user has staff access to
|
||||
the course of the location.
|
||||
|
||||
location: something that can be passed to Location
|
||||
descriptor: something that has a location attribute
|
||||
"""
|
||||
return _has_staff_access_to_location(user, descriptor.location)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ def yield_module_descendents(module):
|
||||
stack.extend( next_module.get_display_items() )
|
||||
yield next_module
|
||||
|
||||
def grade(student, request, course, student_module_cache=None):
|
||||
def grade(student, request, course, student_module_cache=None, keep_raw_scores=False):
|
||||
"""
|
||||
This grades a student as quickly as possible. It retuns the
|
||||
output from the course grader, augmented with the final letter
|
||||
@@ -38,11 +38,13 @@ def grade(student, request, course, student_module_cache=None):
|
||||
up the grade. (For display)
|
||||
- grade_breakdown : A breakdown of the major components that
|
||||
make up the final grade. (For display)
|
||||
- keep_raw_scores : if True, then value for key 'raw_scores' contains scores for every graded module
|
||||
|
||||
More information on the format is in the docstring for CourseGrader.
|
||||
"""
|
||||
|
||||
grading_context = course.grading_context
|
||||
raw_scores = []
|
||||
|
||||
if student_module_cache == None:
|
||||
student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors'])
|
||||
@@ -83,7 +85,7 @@ def grade(student, request, course, student_module_cache=None):
|
||||
if correct is None and total is None:
|
||||
continue
|
||||
|
||||
if settings.GENERATE_PROFILE_SCORES:
|
||||
if settings.GENERATE_PROFILE_SCORES: # for debugging!
|
||||
if total > 1:
|
||||
correct = random.randrange(max(total - 2, 1), total + 1)
|
||||
else:
|
||||
@@ -97,6 +99,8 @@ def grade(student, request, course, student_module_cache=None):
|
||||
scores.append(Score(correct, total, graded, module.metadata.get('display_name')))
|
||||
|
||||
section_total, graded_total = graders.aggregate_scores(scores, section_name)
|
||||
if keep_raw_scores:
|
||||
raw_scores += scores
|
||||
else:
|
||||
section_total = Score(0.0, 1.0, False, section_name)
|
||||
graded_total = Score(0.0, 1.0, True, section_name)
|
||||
@@ -117,7 +121,10 @@ def grade(student, request, course, student_module_cache=None):
|
||||
|
||||
letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent'])
|
||||
grade_summary['grade'] = letter_grade
|
||||
|
||||
grade_summary['totaled_scores'] = totaled_scores # make this available, eg for instructor download & debugging
|
||||
if keep_raw_scores:
|
||||
grade_summary['raw_scores'] = raw_scores # way to get all RAW scores out to instructor
|
||||
# so grader can be double-checked
|
||||
return grade_summary
|
||||
|
||||
def grade_for_percentage(grade_cutoffs, percentage):
|
||||
|
||||
@@ -361,96 +361,3 @@ def progress(request, course_id, student_id=None):
|
||||
|
||||
|
||||
|
||||
# ======== Instructor views =============================================================================
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def gradebook(request, course_id):
|
||||
"""
|
||||
Show the gradebook for this course:
|
||||
- only displayed to course staff
|
||||
- shows students who are enrolled.
|
||||
"""
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
|
||||
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
|
||||
|
||||
# TODO (vshnayder): implement pagination.
|
||||
enrolled_students = enrolled_students[:1000] # HACK!
|
||||
|
||||
student_info = [{'username': student.username,
|
||||
'id': student.id,
|
||||
'email': student.email,
|
||||
'grade_summary': grades.grade(student, request, course),
|
||||
'realname': UserProfile.objects.get(user=student).name
|
||||
}
|
||||
for student in enrolled_students]
|
||||
|
||||
return render_to_response('courseware/gradebook.html', {'students': student_info,
|
||||
'course': course,
|
||||
'course_id': course_id,
|
||||
# Checked above
|
||||
'staff_access': True,})
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def grade_summary(request, course_id):
|
||||
"""Display the grade summary for a course."""
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
|
||||
# For now, just a static page
|
||||
context = {'course': course,
|
||||
'staff_access': True,}
|
||||
return render_to_response('courseware/grade_summary.html', context)
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def instructor_dashboard(request, course_id):
|
||||
"""Display the instructor dashboard for a course."""
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
|
||||
# For now, just a static page
|
||||
context = {'course': course,
|
||||
'staff_access': True,}
|
||||
|
||||
return render_to_response('courseware/instructor_dashboard.html', context)
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def enroll_students(request, course_id):
|
||||
''' Allows a staff member to enroll students in a course.
|
||||
|
||||
This is a short-term hack for Berkeley courses launching fall
|
||||
2012. In the long term, we would like functionality like this, but
|
||||
we would like both the instructor and the student to agree. Right
|
||||
now, this allows any instructor to add students to their course,
|
||||
which we do not want.
|
||||
|
||||
It is poorly written and poorly tested, but it's designed to be
|
||||
stripped out.
|
||||
'''
|
||||
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
existing_students = [ce.user.email for ce in CourseEnrollment.objects.filter(course_id = course_id)]
|
||||
|
||||
if 'new_students' in request.POST:
|
||||
new_students = request.POST['new_students'].split('\n')
|
||||
else:
|
||||
new_students = []
|
||||
new_students = [s.strip() for s in new_students]
|
||||
|
||||
added_students = []
|
||||
rejected_students = []
|
||||
|
||||
for student in new_students:
|
||||
try:
|
||||
nce = CourseEnrollment(user=User.objects.get(email = student), course_id = course_id)
|
||||
nce.save()
|
||||
added_students.append(student)
|
||||
except:
|
||||
rejected_students.append(student)
|
||||
|
||||
return render_to_response("enroll_students.html", {'course':course_id,
|
||||
'existing_students': existing_students,
|
||||
'added_students': added_students,
|
||||
'rejected_students': rejected_students,
|
||||
'debug':new_students})
|
||||
|
||||
0
lms/djangoapps/instructor/__init__.py
Normal file
0
lms/djangoapps/instructor/__init__.py
Normal file
84
lms/djangoapps/instructor/tests.py
Normal file
84
lms/djangoapps/instructor/tests.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Unit tests for instructor dashboard
|
||||
|
||||
Based on (and depends on) unit tests for courseware.
|
||||
|
||||
Notes for running by hand:
|
||||
|
||||
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/instructor
|
||||
"""
|
||||
|
||||
import courseware.tests.tests as ct
|
||||
|
||||
from nose import SkipTest
|
||||
from mock import patch, Mock
|
||||
from override_settings import override_settings
|
||||
|
||||
# Need access to internal func to put users in the right group
|
||||
from courseware.access import _course_staff_group_name
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
import xmodule.modulestore.django
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE)
|
||||
class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader):
|
||||
'''
|
||||
Check for download of csv
|
||||
'''
|
||||
|
||||
def setUp(self):
|
||||
xmodule.modulestore.django._MODULESTORES = {}
|
||||
courses = modulestore().get_courses()
|
||||
|
||||
def find_course(name):
|
||||
"""Assumes the course is present"""
|
||||
return [c for c in courses if c.location.course==name][0]
|
||||
|
||||
self.full = find_course("full")
|
||||
self.toy = find_course("toy")
|
||||
|
||||
# Create two accounts
|
||||
self.student = 'view@test.com'
|
||||
self.instructor = 'view2@test.com'
|
||||
self.password = 'foo'
|
||||
self.create_account('u1', self.student, self.password)
|
||||
self.create_account('u2', self.instructor, self.password)
|
||||
self.activate_user(self.student)
|
||||
self.activate_user(self.instructor)
|
||||
|
||||
group_name = _course_staff_group_name(self.toy.location)
|
||||
g = Group.objects.create(name=group_name)
|
||||
g.user_set.add(ct.user(self.instructor))
|
||||
|
||||
self.logout()
|
||||
self.login(self.instructor, self.password)
|
||||
self.enroll(self.toy)
|
||||
|
||||
|
||||
def test_download_grades_csv(self):
|
||||
print "running test_download_grades_csv"
|
||||
course = self.toy
|
||||
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
|
||||
msg = "url = %s\n" % url
|
||||
response = self.client.post(url, {'action': 'Download CSV of all student grades for this course',
|
||||
})
|
||||
msg += "instructor dashboard download csv grades: response = '%s'\n" % response
|
||||
|
||||
self.assertEqual(response['Content-Type'],'text/csv',msg)
|
||||
|
||||
cdisp = response['Content-Disposition'].replace('TT_2012','2012') # jenkins course_id is TT_2012_Fall instead of 2012_Fall?
|
||||
msg += "cdisp = '%s'\n" % cdisp
|
||||
self.assertEqual(cdisp,'attachment; filename=grades_edX/toy/2012_Fall.csv',msg)
|
||||
|
||||
body = response.content.replace('\r','')
|
||||
msg += "body = '%s'\n" % body
|
||||
|
||||
expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final"
|
||||
"2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0.0","0.0"
|
||||
'''
|
||||
self.assertEqual(body, expected_body, msg)
|
||||
355
lms/djangoapps/instructor/views.py
Normal file
355
lms/djangoapps/instructor/views.py
Normal file
@@ -0,0 +1,355 @@
|
||||
# ======== Instructor views =============================================================================
|
||||
|
||||
import csv
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib
|
||||
|
||||
import track.views
|
||||
|
||||
from functools import partial
|
||||
from collections import defaultdict
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.context_processors import csrf
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
#from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.cache import cache_control
|
||||
|
||||
from courseware import grades
|
||||
from courseware.access import has_access, get_access_group_name
|
||||
from courseware.courses import (get_course_with_access, get_courses_by_university)
|
||||
from student.models import UserProfile
|
||||
|
||||
from student.models import UserTestGroup, CourseEnrollment
|
||||
from util.cache import cache, cache_if_anonymous
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
|
||||
from xmodule.modulestore.search import path_to_location
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
template_imports = {'urllib': urllib}
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def instructor_dashboard(request, course_id):
|
||||
"""Display the instructor dashboard for a course."""
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
|
||||
instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists
|
||||
|
||||
msg = ''
|
||||
# msg += ('POST=%s' % dict(request.POST)).replace('<','<')
|
||||
|
||||
def escape(s):
|
||||
"""escape HTML special characters in string"""
|
||||
return str(s).replace('<', '<').replace('>', '>')
|
||||
|
||||
# assemble some course statistics for output to instructor
|
||||
datatable = {'header': ['Statistic', 'Value'],
|
||||
'title': 'Course Statistics At A Glance',
|
||||
}
|
||||
data = [['# Enrolled', CourseEnrollment.objects.filter(course_id=course_id).count()]]
|
||||
data += compute_course_stats(course).items()
|
||||
if request.user.is_staff:
|
||||
data.append(['metadata', escape(str(course.metadata))])
|
||||
datatable['data'] = data
|
||||
|
||||
def return_csv(fn, datatable):
|
||||
response = HttpResponse(mimetype='text/csv')
|
||||
response['Content-Disposition'] = 'attachment; filename=%s' % fn
|
||||
writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
|
||||
writer.writerow(datatable['header'])
|
||||
for datarow in datatable['data']:
|
||||
writer.writerow(datarow)
|
||||
return response
|
||||
|
||||
def get_staff_group(course):
|
||||
staffgrp = get_access_group_name(course, 'staff')
|
||||
try:
|
||||
group = Group.objects.get(name=staffgrp)
|
||||
except Group.DoesNotExist:
|
||||
group = Group(name=staffgrp) # create the group
|
||||
group.save()
|
||||
return group
|
||||
|
||||
# process actions from form POST
|
||||
action = request.POST.get('action', '')
|
||||
|
||||
if 'GIT pull' in action:
|
||||
data_dir = course.metadata['data_dir']
|
||||
log.debug('git pull %s' % (data_dir))
|
||||
gdir = settings.DATA_DIR / data_dir
|
||||
if not os.path.exists(gdir):
|
||||
msg += "====> ERROR in gitreload - no such directory %s" % gdir
|
||||
else:
|
||||
cmd = "cd %s; git reset --hard HEAD; git clean -f -d; git pull origin; chmod g+w course.xml" % gdir
|
||||
msg += "git pull on %s:<p>" % data_dir
|
||||
msg += "<pre>%s</pre></p>" % escape(os.popen(cmd).read())
|
||||
track.views.server_track(request, 'git pull %s' % data_dir, {}, page='idashboard')
|
||||
|
||||
if 'Reload course' in action:
|
||||
log.debug('reloading %s (%s)' % (course_id, course))
|
||||
try:
|
||||
data_dir = course.metadata['data_dir']
|
||||
modulestore().try_load_course(data_dir)
|
||||
msg += "<br/><p>Course reloaded from %s</p>" % data_dir
|
||||
track.views.server_track(request, 'reload %s' % data_dir, {}, page='idashboard')
|
||||
course_errors = modulestore().get_item_errors(course.location)
|
||||
msg += '<ul>'
|
||||
for cmsg, cerr in course_errors:
|
||||
msg += "<li>%s: <pre>%s</pre>" % (cmsg,escape(cerr))
|
||||
msg += '</ul>'
|
||||
except Exception as err:
|
||||
msg += '<br/><p>Error: %s</p>' % escape(err)
|
||||
|
||||
elif action == 'Dump list of enrolled students':
|
||||
log.debug(action)
|
||||
datatable = get_student_grade_summary_data(request, course, course_id, get_grades=False)
|
||||
datatable['title'] = 'List of students enrolled in %s' % course_id
|
||||
track.views.server_track(request, 'list-students', {}, page='idashboard')
|
||||
|
||||
elif 'Dump Grades' in action:
|
||||
log.debug(action)
|
||||
datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True)
|
||||
datatable['title'] = 'Summary Grades of students enrolled in %s' % course_id
|
||||
track.views.server_track(request, 'dump-grades', {}, page='idashboard')
|
||||
|
||||
elif 'Dump all RAW grades' in action:
|
||||
log.debug(action)
|
||||
datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True,
|
||||
get_raw_scores=True)
|
||||
datatable['title'] = 'Raw Grades of students enrolled in %s' % course_id
|
||||
track.views.server_track(request, 'dump-grades-raw', {}, page='idashboard')
|
||||
|
||||
elif 'Download CSV of all student grades' in action:
|
||||
track.views.server_track(request, 'dump-grades-csv', {}, page='idashboard')
|
||||
return return_csv('grades_%s.csv' % course_id,
|
||||
get_student_grade_summary_data(request, course, course_id))
|
||||
|
||||
elif 'Download CSV of all RAW grades' in action:
|
||||
track.views.server_track(request, 'dump-grades-csv-raw', {}, page='idashboard')
|
||||
return return_csv('grades_%s_raw.csv' % course_id,
|
||||
get_student_grade_summary_data(request, course, course_id, get_raw_scores=True))
|
||||
|
||||
elif 'List course staff' in action:
|
||||
group = get_staff_group(course)
|
||||
msg += 'Staff group = %s' % group.name
|
||||
log.debug('staffgrp=%s' % group.name)
|
||||
uset = group.user_set.all()
|
||||
datatable = {'header': ['Username', 'Full name']}
|
||||
datatable['data'] = [[x.username, x.profile.name] for x in uset]
|
||||
datatable['title'] = 'List of Staff in course %s' % course_id
|
||||
track.views.server_track(request, 'list-staff', {}, page='idashboard')
|
||||
|
||||
elif action == 'Add course staff':
|
||||
uname = request.POST['staffuser']
|
||||
try:
|
||||
user = User.objects.get(username=uname)
|
||||
except User.DoesNotExist:
|
||||
msg += '<font color="red">Error: unknown username "%s"</font>' % uname
|
||||
user = None
|
||||
if user is not None:
|
||||
group = get_staff_group(course)
|
||||
msg += '<font color="green">Added %s to staff group = %s</font>' % (user, group.name)
|
||||
log.debug('staffgrp=%s' % group.name)
|
||||
user.groups.add(group)
|
||||
track.views.server_track(request, 'add-staff %s' % user, {}, page='idashboard')
|
||||
|
||||
elif action == 'Remove course staff':
|
||||
uname = request.POST['staffuser']
|
||||
try:
|
||||
user = User.objects.get(username=uname)
|
||||
except User.DoesNotExist:
|
||||
msg += '<font color="red">Error: unknown username "%s"</font>' % uname
|
||||
user = None
|
||||
if user is not None:
|
||||
group = get_staff_group(course)
|
||||
msg += '<font color="green">Removed %s from staff group = %s</font>' % (user, group.name)
|
||||
log.debug('staffgrp=%s' % group.name)
|
||||
user.groups.remove(group)
|
||||
track.views.server_track(request, 'remove-staff %s' % user, {}, page='idashboard')
|
||||
|
||||
# For now, mostly a static page
|
||||
context = {'course': course,
|
||||
'staff_access': True,
|
||||
'admin_access': request.user.is_staff,
|
||||
'instructor_access': instructor_access,
|
||||
'datatable': datatable,
|
||||
'msg': msg,
|
||||
}
|
||||
|
||||
return render_to_response('courseware/instructor_dashboard.html', context)
|
||||
|
||||
|
||||
def get_student_grade_summary_data(request, course, course_id, get_grades=True, get_raw_scores=False):
|
||||
'''
|
||||
Return data arrays with student identity and grades for specified course.
|
||||
|
||||
course = CourseDescriptor
|
||||
course_id = course ID
|
||||
|
||||
Note: both are passed in, only because instructor_dashboard already has them already.
|
||||
|
||||
returns datatable = dict(header=header, data=data)
|
||||
where
|
||||
|
||||
header = list of strings labeling the data fields
|
||||
data = list (one per student) of lists of data corresponding to the fields
|
||||
|
||||
If get_raw_scores=True, then instead of grade summaries, the raw grades for all graded modules are returned.
|
||||
|
||||
'''
|
||||
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
|
||||
|
||||
header = ['ID', 'Username', 'Full Name', 'edX email', 'External email']
|
||||
if get_grades:
|
||||
# just to construct the header
|
||||
gradeset = grades.grade(enrolled_students[0], request, course, keep_raw_scores=get_raw_scores)
|
||||
# log.debug('student %s gradeset %s' % (enrolled_students[0], gradeset))
|
||||
if get_raw_scores:
|
||||
header += [score.section for score in gradeset['raw_scores']]
|
||||
else:
|
||||
header += [x['label'] for x in gradeset['section_breakdown']]
|
||||
|
||||
datatable = {'header': header}
|
||||
data = []
|
||||
|
||||
for student in enrolled_students:
|
||||
datarow = [ student.id, student.username, student.profile.name, student.email ]
|
||||
try:
|
||||
datarow.append(student.externalauthmap.external_email)
|
||||
except: # ExternalAuthMap.DoesNotExist
|
||||
datarow.append('')
|
||||
|
||||
if get_grades:
|
||||
gradeset = grades.grade(student, request, course, keep_raw_scores=get_raw_scores)
|
||||
# log.debug('student=%s, gradeset=%s' % (student,gradeset))
|
||||
if get_raw_scores:
|
||||
datarow += [score.earned for score in gradeset['raw_scores']]
|
||||
else:
|
||||
datarow += [x['percent'] for x in gradeset['section_breakdown']]
|
||||
|
||||
data.append(datarow)
|
||||
datatable['data'] = data
|
||||
return datatable
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def gradebook(request, course_id):
|
||||
"""
|
||||
Show the gradebook for this course:
|
||||
- only displayed to course staff
|
||||
- shows students who are enrolled.
|
||||
"""
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
|
||||
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
|
||||
|
||||
# TODO (vshnayder): implement pagination.
|
||||
enrolled_students = enrolled_students[:1000] # HACK!
|
||||
|
||||
student_info = [{'username': student.username,
|
||||
'id': student.id,
|
||||
'email': student.email,
|
||||
'grade_summary': grades.grade(student, request, course),
|
||||
'realname': student.profile.name,
|
||||
}
|
||||
for student in enrolled_students]
|
||||
|
||||
return render_to_response('courseware/gradebook.html', {'students': student_info,
|
||||
'course': course,
|
||||
'course_id': course_id,
|
||||
# Checked above
|
||||
'staff_access': True, })
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def grade_summary(request, course_id):
|
||||
"""Display the grade summary for a course."""
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
|
||||
# For now, just a static page
|
||||
context = {'course': course,
|
||||
'staff_access': True, }
|
||||
return render_to_response('courseware/grade_summary.html', context)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def enroll_students(request, course_id):
|
||||
''' Allows a staff member to enroll students in a course.
|
||||
|
||||
This is a short-term hack for Berkeley courses launching fall
|
||||
2012. In the long term, we would like functionality like this, but
|
||||
we would like both the instructor and the student to agree. Right
|
||||
now, this allows any instructor to add students to their course,
|
||||
which we do not want.
|
||||
|
||||
It is poorly written and poorly tested, but it's designed to be
|
||||
stripped out.
|
||||
'''
|
||||
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
existing_students = [ce.user.email for ce in CourseEnrollment.objects.filter(course_id=course_id)]
|
||||
|
||||
if 'new_students' in request.POST:
|
||||
new_students = request.POST['new_students'].split('\n')
|
||||
else:
|
||||
new_students = []
|
||||
new_students = [s.strip() for s in new_students]
|
||||
|
||||
added_students = []
|
||||
rejected_students = []
|
||||
|
||||
for student in new_students:
|
||||
try:
|
||||
nce = CourseEnrollment(user=User.objects.get(email=student), course_id=course_id)
|
||||
nce.save()
|
||||
added_students.append(student)
|
||||
except:
|
||||
rejected_students.append(student)
|
||||
|
||||
return render_to_response("enroll_students.html", {'course': course_id,
|
||||
'existing_students': existing_students,
|
||||
'added_students': added_students,
|
||||
'rejected_students': rejected_students,
|
||||
'debug': new_students})
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_course_stats(course):
|
||||
'''
|
||||
Compute course statistics, including number of problems, videos, html.
|
||||
|
||||
course is a CourseDescriptor from the xmodule system.
|
||||
'''
|
||||
|
||||
# walk the course by using get_children() until we come to the leaves; count the
|
||||
# number of different leaf types
|
||||
|
||||
counts = defaultdict(int)
|
||||
|
||||
def walk(module):
|
||||
children = module.get_children()
|
||||
category = module.__class__.__name__ # HtmlDescriptor, CapaDescriptor, ...
|
||||
counts[category] += 1
|
||||
for c in children:
|
||||
walk(c)
|
||||
|
||||
walk(course)
|
||||
stats = dict(counts) # number of each kind of module
|
||||
return stats
|
||||
@@ -67,7 +67,10 @@ class Command(BaseCommand):
|
||||
password = GenPasswd(12)
|
||||
|
||||
# get name from kerberos
|
||||
kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip()
|
||||
try:
|
||||
kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip()
|
||||
except:
|
||||
kname = ''
|
||||
name = raw_input('Full name: [%s] ' % kname).strip()
|
||||
if name=='':
|
||||
name = kname
|
||||
|
||||
@@ -64,6 +64,9 @@ MITX_FEATURES = {
|
||||
# university to use for branding purposes
|
||||
'SUBDOMAIN_BRANDING': False,
|
||||
|
||||
'FORCE_UNIVERSITY_DOMAIN': False, # set this to the university domain to use, as an override to HTTP_HOST
|
||||
# set to None to do no university selection
|
||||
|
||||
'ENABLE_TEXTBOOK' : True,
|
||||
'ENABLE_DISCUSSION' : False,
|
||||
'ENABLE_DISCUSSION_SERVICE': True,
|
||||
@@ -604,6 +607,7 @@ INSTALLED_APPS = (
|
||||
'track',
|
||||
'util',
|
||||
'certificates',
|
||||
'instructor',
|
||||
|
||||
#For the wiki
|
||||
'wiki', # The new django-wiki from benjaoming
|
||||
|
||||
@@ -17,6 +17,7 @@ MITX_FEATURES['DISABLE_START_DATES'] = True
|
||||
MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True
|
||||
MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up
|
||||
MITX_FEATURES['SUBDOMAIN_BRANDING'] = True
|
||||
MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses if in dev (ie don't use HTTP_HOST)
|
||||
|
||||
WIKI_ENABLED = True
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ MITX_FEATURES['ENABLE_DISCUSSION'] = False
|
||||
MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = True # require that user be in the staff_* group to be able to enroll
|
||||
MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False
|
||||
MITX_FEATURES['SUBDOMAIN_BRANDING'] = False
|
||||
MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses if in dev (ie don't use HTTP_HOST)
|
||||
|
||||
MITX_FEATURES['DISABLE_START_DATES'] = True
|
||||
# MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss
|
||||
@@ -28,6 +29,9 @@ if ('edxvm' in myhost) or ('ocw' in myhost):
|
||||
MITX_FEATURES['USE_XQA_SERVER'] = 'https://qisx.mit.edu/xqa' # needs to be ssl or browser blocks it
|
||||
MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss
|
||||
|
||||
if ('ocw' in myhost):
|
||||
MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = False
|
||||
|
||||
if ('domU' in myhost):
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
MITX_FEATURES['REROUTE_ACTIVATION_EMAIL'] = 'ichuang@mitx.mit.edu' # nonempty string = address for all activation emails
|
||||
|
||||
@@ -8,17 +8,99 @@
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='instructor'" />
|
||||
|
||||
<style type="text/css">
|
||||
table.stat_table {
|
||||
font-family: verdana,arial,sans-serif;
|
||||
font-size:11px;
|
||||
color:#333333;
|
||||
border-width: 1px;
|
||||
border-color: #666666;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.stat_table th {
|
||||
border-width: 1px;
|
||||
padding: 8px;
|
||||
border-style: solid;
|
||||
border-color: #666666;
|
||||
background-color: #dedede;
|
||||
}
|
||||
table.stat_table td {
|
||||
border-width: 1px;
|
||||
padding: 8px;
|
||||
border-style: solid;
|
||||
border-color: #666666;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="container">
|
||||
<div class="instructor-dashboard-wrapper">
|
||||
<section class="instructor-dashboard-content">
|
||||
<h1>Instructor Dashboard</h1>
|
||||
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
|
||||
|
||||
<p>
|
||||
<a href="${reverse('gradebook', kwargs=dict(course_id=course.id))}">Gradebook</a>
|
||||
|
||||
<p>
|
||||
<a href="${reverse('grade_summary', kwargs=dict(course_id=course.id))}">Grade summary</a>
|
||||
|
||||
<p>
|
||||
<input type="submit" name="action" value="Dump list of enrolled students">
|
||||
|
||||
<p>
|
||||
<input type="submit" name="action" value="Dump Grades for all students in this course">
|
||||
<input type="submit" name="action" value="Download CSV of all student grades for this course">
|
||||
|
||||
<p>
|
||||
<input type="submit" name="action" value="Dump all RAW grades for all students in this course">
|
||||
<input type="submit" name="action" value="Download CSV of all RAW grades">
|
||||
|
||||
%if instructor_access:
|
||||
<hr width="40%" style="align:left">
|
||||
<p>
|
||||
<input type="submit" name="action" value="List course staff members">
|
||||
<p>
|
||||
<input type="text" name="staffuser"> <input type="submit" name="action" value="Remove course staff">
|
||||
<input type="submit" name="action" value="Add course staff">
|
||||
<hr width="40%" style="align:left">
|
||||
%endif
|
||||
|
||||
%if admin_access:
|
||||
<p>
|
||||
<input type="submit" name="action" value="Reload course from XML files">
|
||||
<input type="submit" name="action" value="GIT pull and Reload course">
|
||||
%endif
|
||||
|
||||
</form>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<p>
|
||||
<hr width="100%">
|
||||
<h2>${datatable['title']}</h2>
|
||||
<table class="stat_table">
|
||||
<tr>
|
||||
%for hname in datatable['header']:
|
||||
<th>${hname}</th>
|
||||
%endfor
|
||||
</tr>
|
||||
%for row in datatable['data']:
|
||||
<tr>
|
||||
%for value in row:
|
||||
<td>${value}</td>
|
||||
%endfor
|
||||
</tr>
|
||||
%endfor
|
||||
</table>
|
||||
</p>
|
||||
|
||||
%if msg:
|
||||
<p>${msg}</p>
|
||||
%endif
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -32,6 +32,7 @@ ${module_content}
|
||||
<h2>Staff Debug</h2>
|
||||
</header>
|
||||
<div class="staff_info" style="display:block">
|
||||
is_released = ${is_released}
|
||||
location = ${location | h}
|
||||
github = <a href="${edit_link}">${edit_link | h}</a>
|
||||
%if source_file:
|
||||
|
||||
@@ -153,14 +153,14 @@ if settings.COURSEWARE_ENABLED:
|
||||
|
||||
# For the instructor
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor$',
|
||||
'courseware.views.instructor_dashboard', name="instructor_dashboard"),
|
||||
'instructor.views.instructor_dashboard', name="instructor_dashboard"),
|
||||
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$',
|
||||
'courseware.views.gradebook', name='gradebook'),
|
||||
'instructor.views.gradebook', name='gradebook'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/grade_summary$',
|
||||
'courseware.views.grade_summary', name='grade_summary'),
|
||||
'instructor.views.grade_summary', name='grade_summary'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/enroll_students$',
|
||||
'courseware.views.enroll_students', name='enroll_students'),
|
||||
'instructor.views.enroll_students', name='enroll_students'),
|
||||
)
|
||||
|
||||
# discussion forums live within courseware, so courseware must be enabled first
|
||||
|
||||
Reference in New Issue
Block a user