Commit ad261706 by David Ormsbee

Merge pull request #1263 from MITx/feature/ichuang/instructor-dashboard-upgrade3

Upgrade to instructor dashboard: enrollment, offline grades, remote gradebook
parents b067b0bf 4f869ad3
...@@ -12,6 +12,8 @@ admin.site.register(UserTestGroup) ...@@ -12,6 +12,8 @@ admin.site.register(UserTestGroup)
admin.site.register(CourseEnrollment) admin.site.register(CourseEnrollment)
admin.site.register(CourseEnrollmentAllowed)
admin.site.register(Registration) admin.site.register(Registration)
admin.site.register(PendingNameChange) admin.site.register(PendingNameChange)
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseEnrollmentAllowed'
db.create_table('student_courseenrollmentallowed', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('email', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)),
))
db.send_create_signal('student', ['CourseEnrollmentAllowed'])
# Adding unique constraint on 'CourseEnrollmentAllowed', fields ['email', 'course_id']
db.create_unique('student_courseenrollmentallowed', ['email', 'course_id'])
def backwards(self, orm):
# Removing unique constraint on 'CourseEnrollmentAllowed', fields ['email', 'course_id']
db.delete_unique('student_courseenrollmentallowed', ['email', 'course_id'])
# Deleting model 'CourseEnrollmentAllowed'
db.delete_table('student_courseenrollmentallowed')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'student.courseenrollment': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseenrollmentallowed': {
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'student.pendingemailchange': {
'Meta': {'object_name': 'PendingEmailChange'},
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.pendingnamechange': {
'Meta': {'object_name': 'PendingNameChange'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.registration': {
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.testcenteruser': {
'Meta': {'object_name': 'TestCenterUser'},
'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'client_candidate_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'company_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
},
'student.userprofile': {
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
},
'student.usertestgroup': {
'Meta': {'object_name': 'UserTestGroup'},
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
}
}
complete_apps = ['student']
\ No newline at end of file
...@@ -49,7 +49,6 @@ from django.db.models.signals import post_save ...@@ -49,7 +49,6 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
import comment_client as cc import comment_client as cc
from django_comment_client.models import Role
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -262,15 +261,23 @@ class CourseEnrollment(models.Model): ...@@ -262,15 +261,23 @@ class CourseEnrollment(models.Model):
return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created) return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created)
@receiver(post_save, sender=CourseEnrollment) class CourseEnrollmentAllowed(models.Model):
def assign_default_role(sender, instance, **kwargs): """
if instance.user.is_staff: Table of users (specified by email address strings) who are allowed to enroll in a specified course.
role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0] The user may or may not (yet) exist. Enrollment by users listed in this table is allowed
else: even if the enrollment time window is past.
role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0] """
email = models.CharField(max_length=255, db_index=True)
course_id = models.CharField(max_length=255, db_index=True)
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
class Meta:
unique_together = (('email', 'course_id'), )
def __unicode__(self):
return "[CourseEnrollmentAllowed] %s: %s (%s)" % (self.email, self.course_id, self.created)
logging.info("assign_default_role: adding %s as %s" % (instance.user, role))
instance.user.roles.add(role)
#cache_relation(User.profile) #cache_relation(User.profile)
......
Grades can be pushed to a remote gradebook, and course enrollment membership can be pulled from a remote gradebook. This file documents how to setup such a remote gradebook, and what the API should be for writing new remote gradebook "xservers".
1. Definitions
An "xserver" is a web-based server that is part of the MITx eco system. There are a number of "xserver" programs, including one which does python code grading, an xqueue server, and graders for other coding languages.
"Stellar" is the MIT on-campus gradebook system.
2. Setup
The remote gradebook xserver should be specified in the lms.envs configuration using
MITX_FEATURES[REMOTE_GRADEBOOK_URL]
Each course, in addition, should define the name of the gradebook being used. A class "section" may also be specified. This goes in the policy.json file, eg:
"remote_gradebook": {
"name" : "STELLAR:/project/mitxdemosite",
"section" : "r01"
},
3. The API for the remote gradebook xserver is an almost RESTful service model, which only employs POSTs, to the xserver url, with form data for the fields:
- submit: get-assignments, get-membership, post-grades, or get-sections
- gradebook: name of gradebook
- user: username of staff person initiating the request (for logging)
- section: (optional) name of section
The return body content should be a JSON string, of the format {'msg': message, 'data': data}. The message is displayed in the instructor dashboard.
The data is a list of dicts (associative arrays). Each dict should be key:value.
## For submit=post-grades:
A file is also posted, with the field name "datafile". This file is CSV format, with two columns, one being "External email" and the other being the name of the assignment (that column contains the grades for the assignment).
## For submit=get-assignments
data keys = "AssignmentName"
## For submit=get-membership
data keys = "email", "name", "section"
## For submit=get-sections
data keys = "SectionName"
...@@ -13,6 +13,8 @@ from xmodule.modulestore import Location ...@@ -13,6 +13,8 @@ from xmodule.modulestore import Location
from xmodule.timeparse import parse_time from xmodule.timeparse import parse_time
from xmodule.x_module import XModule, XModuleDescriptor from xmodule.x_module import XModule, XModuleDescriptor
from student.models import CourseEnrollmentAllowed
DEBUG_ACCESS = False DEBUG_ACCESS = False
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -124,6 +126,11 @@ def _has_access_course_desc(user, course, action): ...@@ -124,6 +126,11 @@ def _has_access_course_desc(user, course, action):
debug("Allow: in enrollment period") debug("Allow: in enrollment period")
return True return True
# if user is in CourseEnrollmentAllowed with right course_id then can also enroll
if user is not None and CourseEnrollmentAllowed:
if CourseEnrollmentAllowed.objects.filter(email=user.email, course_id=course.id):
return True
# otherwise, need staff access # otherwise, need staff access
return _has_staff_access_to_descriptor(user, course) return _has_staff_access_to_descriptor(user, course)
...@@ -159,13 +166,19 @@ def _has_access_course_desc(user, course, action): ...@@ -159,13 +166,19 @@ def _has_access_course_desc(user, course, action):
return _dispatch(checkers, action, user, course) return _dispatch(checkers, action, user, course)
def _get_access_group_name_course_desc(course, action): def _get_access_group_name_course_desc(course, action):
''' '''
Return name of group which gives staff access to course. Only understands action = 'staff' Return name of group which gives staff access to course. Only understands action = 'staff' and 'instructor'
''' '''
if not action=='staff': if action=='staff':
return [] return _course_staff_group_name(course.location)
return _course_staff_group_name(course.location) elif action=='instructor':
return _course_instructor_group_name(course.location)
return []
def _has_access_error_desc(user, descriptor, action): def _has_access_error_desc(user, descriptor, action):
""" """
......
...@@ -7,3 +7,8 @@ from django.contrib import admin ...@@ -7,3 +7,8 @@ from django.contrib import admin
from django.contrib.auth.models import User from django.contrib.auth.models import User
admin.site.register(StudentModule) admin.site.register(StudentModule)
admin.site.register(OfflineComputedGrade)
admin.site.register(OfflineComputedGradeLog)
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'OfflineComputedGrade'
db.create_table('courseware_offlinecomputedgrade', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)),
('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
('gradeset', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
))
db.send_create_signal('courseware', ['OfflineComputedGrade'])
# Adding unique constraint on 'OfflineComputedGrade', fields ['user', 'course_id']
db.create_unique('courseware_offlinecomputedgrade', ['user_id', 'course_id'])
# Adding model 'OfflineComputedGradeLog'
db.create_table('courseware_offlinecomputedgradelog', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)),
('seconds', self.gf('django.db.models.fields.IntegerField')(default=0)),
('nstudents', self.gf('django.db.models.fields.IntegerField')(default=0)),
))
db.send_create_signal('courseware', ['OfflineComputedGradeLog'])
def backwards(self, orm):
# Removing unique constraint on 'OfflineComputedGrade', fields ['user', 'course_id']
db.delete_unique('courseware_offlinecomputedgrade', ['user_id', 'course_id'])
# Deleting model 'OfflineComputedGrade'
db.delete_table('courseware_offlinecomputedgrade')
# Deleting model 'OfflineComputedGradeLog'
db.delete_table('courseware_offlinecomputedgradelog')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'courseware.offlinecomputedgrade': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'courseware.offlinecomputedgradelog': {
'Meta': {'object_name': 'OfflineComputedGradeLog'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'})
},
'courseware.studentmodule': {
'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['courseware']
\ No newline at end of file
...@@ -177,3 +177,40 @@ class StudentModuleCache(object): ...@@ -177,3 +177,40 @@ class StudentModuleCache(object):
def append(self, student_module): def append(self, student_module):
self.cache.append(student_module) self.cache.append(student_module)
class OfflineComputedGrade(models.Model):
"""
Table of grades computed offline for a given user and course.
"""
user = models.ForeignKey(User, db_index=True)
course_id = models.CharField(max_length=255, db_index=True)
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
updated = models.DateTimeField(auto_now=True, db_index=True)
gradeset = models.TextField(null=True, blank=True) # grades, stored as JSON
class Meta:
unique_together = (('user', 'course_id'), )
def __unicode__(self):
return "[OfflineComputedGrade] %s: %s (%s) = %s" % (self.user, self.course_id, self.created, self.gradeset)
class OfflineComputedGradeLog(models.Model):
"""
Log of when offline grades are computed.
Use this to be able to show instructor when the last computed grades were done.
"""
class Meta:
ordering = ["-created"]
get_latest_by = "created"
course_id = models.CharField(max_length=255, db_index=True)
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
seconds = models.IntegerField(default=0) # seconds elapsed for computation
nstudents = models.IntegerField(default=0)
def __unicode__(self):
return "[OCGLog] %s: %s" % (self.course_id, self.created)
...@@ -2,6 +2,10 @@ import logging ...@@ -2,6 +2,10 @@ import logging
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.dispatch import receiver
from django.db.models.signals import post_save
from student.models import CourseEnrollment
from courseware.courses import get_course_by_id from courseware.courses import get_course_by_id
...@@ -45,3 +49,14 @@ class Permission(models.Model): ...@@ -45,3 +49,14 @@ class Permission(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
@receiver(post_save, sender=CourseEnrollment)
def assign_default_role(sender, instance, **kwargs):
if instance.user.is_staff:
role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0]
else:
role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0]
logging.info("assign_default_role: adding %s as %s" % (instance.user, role))
instance.user.roles.add(role)
#!/usr/bin/python
#
# django management command: dump grades to csv files
# for use by batch processes
import os, sys, string
import datetime
import json
#import student.models
from instructor.offline_gradecalc import *
from courseware.courses import get_course_by_id
from xmodule.modulestore.django import modulestore
from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Compute grades for all students in a course, and store result in DB.\n"
help += "Usage: compute_grades course_id_or_dir \n"
help += " course_id_or_dir: either course_id or course_dir\n"
help += 'Example course_id: MITx/8.01rq_MW/Classical_Mechanics_Reading_Questions_Fall_2012_MW_Section'
def handle(self, *args, **options):
print "args = ", args
if len(args)>0:
course_id = args[0]
else:
print self.help
return
try:
course = get_course_by_id(course_id)
except Exception as err:
if course_id in modulestore().courses:
course = modulestore().courses[course_id]
else:
print "-----------------------------------------------------------------------------"
print "Sorry, cannot find course %s" % course_id
print "Please provide a course ID or course data directory name, eg content-mit-801rq"
return
print "-----------------------------------------------------------------------------"
print "Computing grades for %s" % (course.id)
offline_grade_calculation(course.id)
# ======== Offline calculation of grades =============================================================================
#
# Computing grades of a large number of students can take a long time. These routines allow grades to
# be computed offline, by a batch process (eg cronjob).
#
# The grades are stored in the OfflineComputedGrade table of the courseware model.
import json
import logging
import time
import courseware.models
from collections import namedtuple
from json import JSONEncoder
from courseware import grades, models
from courseware.courses import get_course_by_id
from django.contrib.auth.models import User, Group
class MyEncoder(JSONEncoder):
def _iterencode(self, obj, markers=None):
if isinstance(obj, tuple) and hasattr(obj, '_asdict'):
gen = self._iterencode_dict(obj._asdict(), markers)
else:
gen = JSONEncoder._iterencode(self, obj, markers)
for chunk in gen:
yield chunk
def offline_grade_calculation(course_id):
'''
Compute grades for all students for a specified course, and save results to the DB.
'''
tstart = time.time()
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username')
enc = MyEncoder()
class DummyRequest(object):
META = {}
def __init__(self):
return
def get_host(self):
return 'edx.mit.edu'
def is_secure(self):
return False
request = DummyRequest()
print "%d enrolled students" % len(enrolled_students)
course = get_course_by_id(course_id)
for student in enrolled_students:
gradeset = grades.grade(student, request, course, keep_raw_scores=True)
gs = enc.encode(gradeset)
ocg, created = models.OfflineComputedGrade.objects.get_or_create(user=student, course_id=course_id)
ocg.gradeset = gs
ocg.save()
print "%s done" % student # print statement used because this is run by a management command
tend = time.time()
dt = tend - tstart
ocgl = models.OfflineComputedGradeLog(course_id=course_id, seconds=dt, nstudents=len(enrolled_students))
ocgl.save()
print ocgl
print "All Done!"
def offline_grades_available(course_id):
'''
Returns False if no offline grades available for specified course.
Otherwise returns latest log field entry about the available pre-computed grades.
'''
ocgl = models.OfflineComputedGradeLog.objects.filter(course_id=course_id)
if not ocgl:
return False
return ocgl.latest('created')
def student_grades(student, request, course, keep_raw_scores=False, use_offline=False):
'''
This is the main interface to get grades. It has the same parameters as grades.grade, as well
as use_offline. If use_offline is True then this will look for an offline computed gradeset in the DB.
'''
if not use_offline:
return grades.grade(student, request, course, keep_raw_scores=keep_raw_scores)
try:
ocg = models.OfflineComputedGrade.objects.get(user=student, course_id=course.id)
except models.OfflineComputedGrade.DoesNotExist:
return dict(raw_scores=[], section_breakdown=[],
msg='Error: no offline gradeset available for %s, %s' % (student, course.id))
return json.loads(ocg.gradeset)
...@@ -2,10 +2,14 @@ ...@@ -2,10 +2,14 @@
from collections import defaultdict from collections import defaultdict
import csv import csv
import json
import logging import logging
import os import os
import requests
import urllib import urllib
from StringIO import StringIO
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.http import HttpResponse from django.http import HttpResponse
...@@ -20,7 +24,7 @@ from courseware.courses import get_course_with_access ...@@ -20,7 +24,7 @@ from courseware.courses import get_course_with_access
from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA
from django_comment_client.utils import has_forum_access from django_comment_client.utils import has_forum_access
from psychometrics import psychoanalyze from psychometrics import psychoanalyze
from student.models import CourseEnrollment from student.models import CourseEnrollment, CourseEnrollmentAllowed
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -28,7 +32,7 @@ from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundErr ...@@ -28,7 +32,7 @@ from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundErr
from xmodule.modulestore.search import path_to_location from xmodule.modulestore.search import path_to_location
import track.views import track.views
from .offline_gradecalc import student_grades, offline_grades_available
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -75,9 +79,12 @@ def instructor_dashboard(request, course_id): ...@@ -75,9 +79,12 @@ def instructor_dashboard(request, course_id):
data.append(['metadata', escape(str(course.metadata))]) data.append(['metadata', escape(str(course.metadata))])
datatable['data'] = data datatable['data'] = data
def return_csv(fn, datatable): def return_csv(fn, datatable, fp=None):
response = HttpResponse(mimetype='text/csv') if fp is None:
response['Content-Disposition'] = 'attachment; filename={0}'.format(fn) response = HttpResponse(mimetype='text/csv')
response['Content-Disposition'] = 'attachment; filename={0}'.format(fn)
else:
response = fp
writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
writer.writerow(datatable['header']) writer.writerow(datatable['header'])
for datarow in datatable['data']: for datarow in datatable['data']:
...@@ -86,16 +93,23 @@ def instructor_dashboard(request, course_id): ...@@ -86,16 +93,23 @@ def instructor_dashboard(request, course_id):
return response return response
def get_staff_group(course): def get_staff_group(course):
staffgrp = get_access_group_name(course, 'staff') return get_group(course, 'staff')
def get_instructor_group(course):
return get_group(course, 'instructor')
def get_group(course, groupname):
grpname = get_access_group_name(course, groupname)
try: try:
group = Group.objects.get(name=staffgrp) group = Group.objects.get(name=grpname)
except Group.DoesNotExist: except Group.DoesNotExist:
group = Group(name=staffgrp) # create the group group = Group(name=grpname) # create the group
group.save() group.save()
return group return group
# process actions from form POST # process actions from form POST
action = request.POST.get('action', '') action = request.POST.get('action', '')
use_offline = request.POST.get('use_offline_grades',False)
if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD']: if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD']:
if 'GIT pull' in action: if 'GIT pull' in action:
...@@ -125,40 +139,99 @@ def instructor_dashboard(request, course_id): ...@@ -125,40 +139,99 @@ def instructor_dashboard(request, course_id):
except Exception as err: except Exception as err:
msg += '<br/><p>Error: {0}</p>'.format(escape(err)) msg += '<br/><p>Error: {0}</p>'.format(escape(err))
if action == 'Dump list of enrolled students': if action == 'Dump list of enrolled students' or action=='List enrolled students':
log.debug(action) log.debug(action)
datatable = get_student_grade_summary_data(request, course, course_id, get_grades=False) datatable = get_student_grade_summary_data(request, course, course_id, get_grades=False, use_offline=use_offline)
datatable['title'] = 'List of students enrolled in {0}'.format(course_id) datatable['title'] = 'List of students enrolled in {0}'.format(course_id)
track.views.server_track(request, 'list-students', {}, page='idashboard') track.views.server_track(request, 'list-students', {}, page='idashboard')
elif 'Dump Grades' in action: elif 'Dump Grades' in action:
log.debug(action) log.debug(action)
datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True) datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True, use_offline=use_offline)
datatable['title'] = 'Summary Grades of students enrolled in {0}'.format(course_id) datatable['title'] = 'Summary Grades of students enrolled in {0}'.format(course_id)
track.views.server_track(request, 'dump-grades', {}, page='idashboard') track.views.server_track(request, 'dump-grades', {}, page='idashboard')
elif 'Dump all RAW grades' in action: elif 'Dump all RAW grades' in action:
log.debug(action) log.debug(action)
datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True, datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True,
get_raw_scores=True) get_raw_scores=True, use_offline=use_offline)
datatable['title'] = 'Raw Grades of students enrolled in {0}'.format(course_id) datatable['title'] = 'Raw Grades of students enrolled in {0}'.format(course_id)
track.views.server_track(request, 'dump-grades-raw', {}, page='idashboard') track.views.server_track(request, 'dump-grades-raw', {}, page='idashboard')
elif 'Download CSV of all student grades' in action: elif 'Download CSV of all student grades' in action:
track.views.server_track(request, 'dump-grades-csv', {}, page='idashboard') track.views.server_track(request, 'dump-grades-csv', {}, page='idashboard')
return return_csv('grades_{0}.csv'.format(course_id), return return_csv('grades_{0}.csv'.format(course_id),
get_student_grade_summary_data(request, course, course_id)) get_student_grade_summary_data(request, course, course_id, use_offline=use_offline))
elif 'Download CSV of all RAW grades' in action: elif 'Download CSV of all RAW grades' in action:
track.views.server_track(request, 'dump-grades-csv-raw', {}, page='idashboard') track.views.server_track(request, 'dump-grades-csv-raw', {}, page='idashboard')
return return_csv('grades_{0}_raw.csv'.format(course_id), return return_csv('grades_{0}_raw.csv'.format(course_id),
get_student_grade_summary_data(request, course, course_id, get_raw_scores=True)) get_student_grade_summary_data(request, course, course_id, get_raw_scores=True, use_offline=use_offline))
elif 'Download CSV of answer distributions' in action: elif 'Download CSV of answer distributions' in action:
track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard') track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard')
return return_csv('answer_dist_{0}.csv'.format(course_id), get_answers_distribution(request, course_id)) return return_csv('answer_dist_{0}.csv'.format(course_id), get_answers_distribution(request, course_id))
#---------------------------------------- #----------------------------------------
# export grades to remote gradebook
elif action=='List assignments available in remote gradebook':
msg2, datatable = _do_remote_gradebook(request.user, course, 'get-assignments')
msg += msg2
elif action=='List assignments available for this course':
log.debug(action)
allgrades = get_student_grade_summary_data(request, course, course_id, get_grades=True, use_offline=use_offline)
assignments = [[x] for x in allgrades['assignments']]
datatable = {'header': ['Assignment Name']}
datatable['data'] = assignments
datatable['title'] = action
msg += 'assignments=<pre>%s</pre>' % assignments
elif action=='List enrolled students matching remote gradebook':
stud_data = get_student_grade_summary_data(request, course, course_id, get_grades=False, use_offline=use_offline)
msg2, rg_stud_data = _do_remote_gradebook(request.user, course, 'get-membership')
datatable = {'header': ['Student email', 'Match?']}
rg_students = [ x['email'] for x in rg_stud_data['retdata'] ]
def domatch(x):
return '<font color="green">yes</font>' if x.email in rg_students else '<font color="red">No</font>'
datatable['data'] = [[x.email, domatch(x)] for x in stud_data['students']]
datatable['title'] = action
elif action in ['Display grades for assignment', 'Export grades for assignment to remote gradebook',
'Export CSV file of grades for assignment']:
log.debug(action)
datatable = {}
aname = request.POST.get('assignment_name','')
if not aname:
msg += "<font color='red'>Please enter an assignment name</font>"
else:
allgrades = get_student_grade_summary_data(request, course, course_id, get_grades=True, use_offline=use_offline)
if aname not in allgrades['assignments']:
msg += "<font color='red'>Invalid assignment name '%s'</font>" % aname
else:
aidx = allgrades['assignments'].index(aname)
datatable = {'header': ['External email', aname]}
datatable['data'] = [[x.email, x.grades[aidx]] for x in allgrades['students']]
datatable['title'] = 'Grades for assignment "%s"' % aname
if 'Export CSV' in action:
# generate and return CSV file
return return_csv('grades %s.csv' % aname, datatable)
elif 'remote gradebook' in action:
fp = StringIO()
return_csv('', datatable, fp=fp)
fp.seek(0)
files = {'datafile': fp}
msg2, dataset = _do_remote_gradebook(request.user, course, 'post-grades', files=files)
msg += msg2
#----------------------------------------
# Admin # Admin
elif 'List course staff' in action: elif 'List course staff' in action:
...@@ -171,6 +244,16 @@ def instructor_dashboard(request, course_id): ...@@ -171,6 +244,16 @@ def instructor_dashboard(request, course_id):
datatable['title'] = 'List of Staff in course {0}'.format(course_id) datatable['title'] = 'List of Staff in course {0}'.format(course_id)
track.views.server_track(request, 'list-staff', {}, page='idashboard') track.views.server_track(request, 'list-staff', {}, page='idashboard')
elif 'List course instructors' in action and request.user.is_staff:
group = get_instructor_group(course)
msg += 'Instructor group = {0}'.format(group.name)
log.debug('instructor grp={0}'.format(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 Instructors in course {0}'.format(course_id)
track.views.server_track(request, 'list-instructors', {}, page='idashboard')
elif action == 'Add course staff': elif action == 'Add course staff':
uname = request.POST['staffuser'] uname = request.POST['staffuser']
try: try:
...@@ -185,6 +268,20 @@ def instructor_dashboard(request, course_id): ...@@ -185,6 +268,20 @@ def instructor_dashboard(request, course_id):
user.groups.add(group) user.groups.add(group)
track.views.server_track(request, 'add-staff {0}'.format(user), {}, page='idashboard') track.views.server_track(request, 'add-staff {0}'.format(user), {}, page='idashboard')
elif action == 'Add instructor' and request.user.is_staff:
uname = request.POST['instructor']
try:
user = User.objects.get(username=uname)
except User.DoesNotExist:
msg += '<font color="red">Error: unknown username "{0}"</font>'.format(uname)
user = None
if user is not None:
group = get_instructor_group(course)
msg += '<font color="green">Added {0} to instructor group = {1}</font>'.format(user, group.name)
log.debug('staffgrp={0}'.format(group.name))
user.groups.add(group)
track.views.server_track(request, 'add-instructor {0}'.format(user), {}, page='idashboard')
elif action == 'Remove course staff': elif action == 'Remove course staff':
uname = request.POST['staffuser'] uname = request.POST['staffuser']
try: try:
...@@ -199,6 +296,20 @@ def instructor_dashboard(request, course_id): ...@@ -199,6 +296,20 @@ def instructor_dashboard(request, course_id):
user.groups.remove(group) user.groups.remove(group)
track.views.server_track(request, 'remove-staff {0}'.format(user), {}, page='idashboard') track.views.server_track(request, 'remove-staff {0}'.format(user), {}, page='idashboard')
elif action == 'Remove instructor' and request.user.is_staff:
uname = request.POST['instructor']
try:
user = User.objects.get(username=uname)
except User.DoesNotExist:
msg += '<font color="red">Error: unknown username "{0}"</font>'.format(uname)
user = None
if user is not None:
group = get_instructor_group(course)
msg += '<font color="green">Removed {0} from instructor group = {1}</font>'.format(user, group.name)
log.debug('instructorgrp={0}'.format(group.name))
user.groups.remove(group)
track.views.server_track(request, 'remove-instructor {0}'.format(user), {}, page='idashboard')
#---------------------------------------- #----------------------------------------
# forum administration # forum administration
...@@ -258,6 +369,71 @@ def instructor_dashboard(request, course_id): ...@@ -258,6 +369,71 @@ def instructor_dashboard(request, course_id):
{}, page='idashboard') {}, page='idashboard')
#---------------------------------------- #----------------------------------------
# enrollment
elif action == 'List students who may enroll but may not have yet signed up':
ceaset = CourseEnrollmentAllowed.objects.filter(course_id=course_id)
datatable = {'header': ['StudentEmail']}
datatable['data'] = [[x.email] for x in ceaset]
datatable['title'] = action
elif action == 'Enroll student':
student = request.POST.get('enstudent','')
ret = _do_enroll_students(course, course_id, student)
datatable = ret['datatable']
elif action == 'Un-enroll student':
student = request.POST.get('enstudent','')
datatable = {}
isok = False
cea = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=student)
if cea:
cea.delete()
msg += "Un-enrolled student with email '%s'" % student
isok = True
try:
nce = CourseEnrollment.objects.get(user=User.objects.get(email=student), course_id=course_id)
nce.delete()
msg += "Un-enrolled student with email '%s'" % student
except Exception as err:
if not isok:
msg += "Error! Failed to un-enroll student with email '%s'\n" % student
msg += str(err) + '\n'
elif action == 'Un-enroll ALL students':
ret = _do_enroll_students(course, course_id, '', overload=True)
datatable = ret['datatable']
elif action == 'Enroll multiple students':
students = request.POST.get('enroll_multiple','')
ret = _do_enroll_students(course, course_id, students)
datatable = ret['datatable']
elif action == 'List sections available in remote gradebook':
msg2, datatable = _do_remote_gradebook(request.user, course, 'get-sections')
msg += msg2
elif action in ['List students in section in remote gradebook',
'Overload enrollment list using remote gradebook',
'Merge enrollment list with remote gradebook']:
section = request.POST.get('gradebook_section','')
msg2, datatable = _do_remote_gradebook(request.user, course, 'get-membership', dict(section=section) )
msg += msg2
if not 'List' in action:
students = ','.join([x['email'] for x in datatable['retdata']])
overload = 'Overload' in action
ret = _do_enroll_students(course, course_id, students, overload=overload)
datatable = ret['datatable']
#----------------------------------------
# psychometrics # psychometrics
elif action == 'Generate Histogram and IRT Plot': elif action == 'Generate Histogram and IRT Plot':
...@@ -270,9 +446,15 @@ def instructor_dashboard(request, course_id): ...@@ -270,9 +446,15 @@ def instructor_dashboard(request, course_id):
problems = psychoanalyze.problems_with_psychometric_data(course_id) problems = psychoanalyze.problems_with_psychometric_data(course_id)
#----------------------------------------
# offline grades?
if use_offline:
msg += "<br/><font color='orange'>Grades from %s</font>" % offline_grades_available(course_id)
#---------------------------------------- #----------------------------------------
# context for rendering # context for rendering
context = {'course': course, context = {'course': course,
'staff_access': True, 'staff_access': True,
'admin_access': request.user.is_staff, 'admin_access': request.user.is_staff,
...@@ -285,16 +467,66 @@ def instructor_dashboard(request, course_id): ...@@ -285,16 +467,66 @@ def instructor_dashboard(request, course_id):
'plots': plots, # psychometrics 'plots': plots, # psychometrics
'course_errors': modulestore().get_item_errors(course.location), 'course_errors': modulestore().get_item_errors(course.location),
'djangopid' : os.getpid(), 'djangopid' : os.getpid(),
'mitx_version' : getattr(settings,'MITX_VERSION_STRING',''),
'offline_grade_log' : offline_grades_available(course_id),
} }
return render_to_response('courseware/instructor_dashboard.html', context) return render_to_response('courseware/instructor_dashboard.html', context)
def _do_remote_gradebook(user, course, action, args=None, files=None):
'''
Perform remote gradebook action. Returns msg, datatable.
'''
rg = course.metadata.get('remote_gradebook','')
if not rg:
msg = "No remote gradebook defined in course metadata"
return msg, {}
rgurl = settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','')
if not rgurl:
msg = "No remote gradebook url defined in settings.MITX_FEATURES"
return msg, {}
rgname = rg.get('name','')
if not rgname:
msg = "No gradebook name defined in course remote_gradebook metadata"
return msg, {}
if args is None:
args = {}
data = dict(submit=action, gradebook=rgname, user=user.email)
data.update(args)
try:
resp = requests.post(rgurl, data=data, verify=False, files=files)
retdict = json.loads(resp.content)
except Exception as err:
msg = "Failed to communicate with gradebook server at %s<br/>" % rgurl
msg += "Error: %s" % err
msg += "<br/>resp=%s" % resp.content
msg += "<br/>data=%s" % data
return msg, {}
msg = '<pre>%s</pre>' % retdict['msg'].replace('\n','<br/>')
retdata = retdict['data'] # a list of dicts
if retdata:
datatable = {'header': retdata[0].keys()}
datatable['data'] = [x.values() for x in retdata]
datatable['title'] = 'Remote gradebook response for %s' % action
datatable['retdata'] = retdata
else:
datatable = {}
return msg, datatable
def _list_course_forum_members(course_id, rolename, datatable): def _list_course_forum_members(course_id, rolename, datatable):
''' '''
Fills in datatable with forum membership information, for a given role, Fills in datatable with forum membership information, for a given role,
so that it will be displayed on instructor dashboard. so that it will be displayed on instructor dashboard.
course_ID = course's ID string course_ID = the ID string for a course
rolename = one of "Administrator", "Moderator", "Community TA" rolename = one of "Administrator", "Moderator", "Community TA"
Returns message status string to append to displayed message, if role is unknown. Returns message status string to append to displayed message, if role is unknown.
...@@ -359,7 +591,7 @@ def _update_forum_role_membership(uname, course, rolename, add_or_remove): ...@@ -359,7 +591,7 @@ def _update_forum_role_membership(uname, course, rolename, add_or_remove):
return msg return msg
def get_student_grade_summary_data(request, course, course_id, get_grades=True, get_raw_scores=False): def get_student_grade_summary_data(request, course, course_id, get_grades=True, get_raw_scores=False, use_offline=False):
''' '''
Return data arrays with student identity and grades for specified course. Return data arrays with student identity and grades for specified course.
...@@ -380,16 +612,18 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True, ...@@ -380,16 +612,18 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True,
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username') enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username')
header = ['ID', 'Username', 'Full Name', 'edX email', 'External email'] header = ['ID', 'Username', 'Full Name', 'edX email', 'External email']
assignments = []
if get_grades and enrolled_students.count() > 0: if get_grades and enrolled_students.count() > 0:
# just to construct the header # just to construct the header
gradeset = grades.grade(enrolled_students[0], request, course, keep_raw_scores=get_raw_scores) gradeset = student_grades(enrolled_students[0], request, course, keep_raw_scores=get_raw_scores, use_offline=use_offline)
# log.debug('student {0} gradeset {1}'.format(enrolled_students[0], gradeset)) # log.debug('student {0} gradeset {1}'.format(enrolled_students[0], gradeset))
if get_raw_scores: if get_raw_scores:
header += [score.section for score in gradeset['raw_scores']] assignments += [score.section for score in gradeset['raw_scores']]
else: else:
header += [x['label'] for x in gradeset['section_breakdown']] assignments += [x['label'] for x in gradeset['section_breakdown']]
header += assignments
datatable = {'header': header} datatable = {'header': header, 'assignments': assignments, 'students': enrolled_students}
data = [] data = []
for student in enrolled_students: for student in enrolled_students:
...@@ -400,20 +634,21 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True, ...@@ -400,20 +634,21 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True,
datarow.append('') datarow.append('')
if get_grades: if get_grades:
gradeset = grades.grade(student, request, course, keep_raw_scores=get_raw_scores) gradeset = student_grades(student, request, course, keep_raw_scores=get_raw_scores, use_offline=use_offline)
# log.debug('student={0}, gradeset={1}'.format(student,gradeset)) log.debug('student={0}, gradeset={1}'.format(student,gradeset))
if get_raw_scores: if get_raw_scores:
datarow += [score.earned for score in gradeset['raw_scores']] # TODO (ichuang) encode Score as dict instead of as list, so score[0] -> score['earned']
sgrades = [(getattr(score,'earned','') or score[0]) for score in gradeset['raw_scores']]
else: else:
datarow += [x['percent'] for x in gradeset['section_breakdown']] sgrades = [x['percent'] for x in gradeset['section_breakdown']]
datarow += sgrades
student.grades = sgrades # store in student object
data.append(datarow) data.append(datarow)
datatable['data'] = data datatable['data'] = data
return datatable return datatable
#-----------------------------------------------------------------------------
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
def gradebook(request, course_id): def gradebook(request, course_id):
...@@ -432,7 +667,7 @@ def gradebook(request, course_id): ...@@ -432,7 +667,7 @@ def gradebook(request, course_id):
student_info = [{'username': student.username, student_info = [{'username': student.username,
'id': student.id, 'id': student.id,
'email': student.email, 'email': student.email,
'grade_summary': grades.grade(student, request, course), 'grade_summary': student_grades(student, request, course),
'realname': student.profile.name, 'realname': student.profile.name,
} }
for student in enrolled_students] for student in enrolled_students]
...@@ -455,6 +690,72 @@ def grade_summary(request, course_id): ...@@ -455,6 +690,72 @@ def grade_summary(request, course_id):
return render_to_response('courseware/grade_summary.html', context) return render_to_response('courseware/grade_summary.html', context)
#-----------------------------------------------------------------------------
# enrollment
def _do_enroll_students(course, course_id, students, overload=False):
"""Do the actual work of enrolling multiple students, presented as a string
of emails separated by commas or returns"""
ns = [x.split('\n') for x in students.split(',')]
new_students = [item for sublist in ns for item in sublist]
new_students = [str(s.strip()) for s in new_students]
new_students_lc = [x.lower() for x in new_students]
if '' in new_students:
new_students.remove('')
status = dict([x,'unprocessed'] for x in new_students)
if overload: # delete all but staff
todelete = CourseEnrollment.objects.filter(course_id=course_id)
for ce in todelete:
if not has_access(ce.user, course, 'staff') and ce.user.email.lower() not in new_students_lc:
status[ce.user.email] = 'deleted'
ce.delete()
else:
status[ce.user.email] = 'is staff'
ceaset = CourseEnrollmentAllowed.objects.filter(course_id=course_id)
for cea in ceaset:
status[cea.email] = 'removed from pending enrollment list'
ceaset.delete()
for student in new_students:
try:
user=User.objects.get(email=student)
except User.DoesNotExist:
# user not signed up yet, put in pending enrollment allowed table
if CourseEnrollmentAllowed.objects.filter(email=student, course_id=course_id):
status[student] = 'user does not exist, enrollment already allowed, pending'
continue
cea = CourseEnrollmentAllowed(email=student, course_id=course_id)
cea.save()
status[student] = 'user does not exist, enrollment allowed, pending'
continue
if CourseEnrollment.objects.filter(user=user, course_id=course_id):
status[student] = 'already enrolled'
continue
try:
nce = CourseEnrollment(user=user, course_id=course_id)
nce.save()
status[student] = 'added'
except:
status[student] = 'rejected'
datatable = {'header': ['StudentEmail', 'action']}
datatable['data'] = [[x, status[x]] for x in status]
datatable['title'] = 'Enrollment of students'
def sf(stat): return [x for x in status if status[x]==stat]
data = dict(added=sf('added'), rejected=sf('rejected')+sf('exists'),
deleted=sf('deleted'), datatable=datatable)
return data
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
def enroll_students(request, course_id): def enroll_students(request, course_id):
...@@ -473,22 +774,10 @@ def enroll_students(request, course_id): ...@@ -473,22 +774,10 @@ def enroll_students(request, course_id):
course = get_course_with_access(request.user, course_id, 'staff') 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)] 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.get('new_students')
new_students = request.POST['new_students'].split('\n') ret = _do_enroll_students(course, course_id, new_students)
else: added_students = ret['added']
new_students = [] rejected_students = ret['rejected']
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, return render_to_response("enroll_students.html", {'course': course_id,
'existing_students': existing_students, 'existing_students': existing_students,
...@@ -497,6 +786,9 @@ def enroll_students(request, course_id): ...@@ -497,6 +786,9 @@ def enroll_students(request, course_id):
'debug': new_students}) 'debug': new_students})
#-----------------------------------------------------------------------------
# answer distribution
def get_answers_distribution(request, course_id): def get_answers_distribution(request, course_id):
""" """
Get the distribution of answers for all graded problems in the course. Get the distribution of answers for all graded problems in the course.
......
...@@ -102,6 +102,10 @@ SUBDOMAIN_BRANDING = { ...@@ -102,6 +102,10 @@ SUBDOMAIN_BRANDING = {
COMMENTS_SERVICE_KEY = "PUT_YOUR_API_KEY_HERE" COMMENTS_SERVICE_KEY = "PUT_YOUR_API_KEY_HERE"
################################# mitx revision string #####################
MITX_VERSION_STRING = os.popen('cd %s; git describe' % REPO_ROOT).read().strip()
################################# Staff grading config ##################### ################################# Staff grading config #####################
STAFF_GRADING_INTERFACE = { STAFF_GRADING_INTERFACE = {
......
...@@ -57,10 +57,13 @@ function goto( mode) ...@@ -57,10 +57,13 @@ function goto( mode)
<a href="#" onclick="goto('Psychometrics');" class="${modeflag.get('Psychometrics')}">Psychometrics</a> | <a href="#" onclick="goto('Psychometrics');" class="${modeflag.get('Psychometrics')}">Psychometrics</a> |
%endif %endif
<a href="#" onclick="goto('Admin');" class="${modeflag.get('Admin')}">Admin</a> | <a href="#" onclick="goto('Admin');" class="${modeflag.get('Admin')}">Admin</a> |
<a href="#" onclick="goto('Forum Admin');" class="${modeflag.get('Forum Admin')}">Forum Admin</a> ] <a href="#" onclick="goto('Forum Admin');" class="${modeflag.get('Forum Admin')}">Forum Admin</a> |
<a href="#" onclick="goto('Enrollment');" class="${modeflag.get('Enrollment')}">Enrollment</a>
]
</h2> </h2>
<div style="text-align:right" id="djangopid">${djangopid}</div> <div style="text-align:right"><span id="djangopid">${djangopid}</span>
| <span id="mitxver">${mitx_version}</span></div>
<form name="idashform" method="POST"> <form name="idashform" method="POST">
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }"> <input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
...@@ -68,6 +71,12 @@ function goto( mode) ...@@ -68,6 +71,12 @@ function goto( mode)
##----------------------------------------------------------------------------- ##-----------------------------------------------------------------------------
%if modeflag.get('Grades'): %if modeflag.get('Grades'):
%if offline_grade_log:
<p><font color='orange'>Pre-computed grades ${offline_grade_log} available: Use?
<input type='checkbox' name='use_offline_grades' value='yes'></font> </p>
%endif
<p> <p>
<a href="${reverse('gradebook', kwargs=dict(course_id=course.id))}">Gradebook</a> <a href="${reverse('gradebook', kwargs=dict(course_id=course.id))}">Gradebook</a>
</p> </p>
...@@ -93,6 +102,42 @@ function goto( mode) ...@@ -93,6 +102,42 @@ function goto( mode)
<p> <p>
<input type="submit" name="action" value="Download CSV of answer distributions"> <input type="submit" name="action" value="Download CSV of answer distributions">
</p> </p>
<hr width="40%" style="align:left">
%if settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','') and instructor_access:
<%
rg = course.metadata.get('remote_gradebook',{})
%>
<h3>Export grades to remote gradebook</h3>
<p>The assignments defined for this course should match the ones
stored in the gradebook, for this to work properly!</p>
<ul>
<li>Gradebook name: <font color="green">${rg.get('name','None defined!')}</font>
<br/>
<br/>
<input type="submit" name="action" value="List assignments available in remote gradebook">
<input type="submit" name="action" value="List enrolled students matching remote gradebook">
<br/>
<br/>
</li>
<li><input type="submit" name="action" value="List assignments available for this course">
<br/>
<br/>
</li>
<li>Assignment name: <input type="text" name="assignment_name" size=40 >
<br/>
<br/>
<input type="submit" name="action" value="Display grades for assignment">
<input type="submit" name="action" value="Export grades for assignment to remote gradebook">
<input type="submit" name="action" value="Export CSV file of grades for assignment">
</li>
</ul>
%endif
%endif %endif
##----------------------------------------------------------------------------- ##-----------------------------------------------------------------------------
...@@ -128,6 +173,16 @@ function goto( mode) ...@@ -128,6 +173,16 @@ function goto( mode)
<hr width="40%" style="align:left"> <hr width="40%" style="align:left">
%endif %endif
%if admin_access:
<hr width="40%" style="align:left">
<p>
<input type="submit" name="action" value="List course instructors">
<p>
<input type="text" name="instructor"> <input type="submit" name="action" value="Remove instructor">
<input type="submit" name="action" value="Add instructor">
<hr width="40%" style="align:left">
%endif
%if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] and admin_access: %if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] and admin_access:
<p> <p>
<input type="submit" name="action" value="Reload course from XML files"> <input type="submit" name="action" value="Reload course from XML files">
...@@ -163,10 +218,52 @@ function goto( mode) ...@@ -163,10 +218,52 @@ function goto( mode)
%endif %endif
%endif %endif
##-----------------------------------------------------------------------------
%if modeflag.get('Enrollment'):
<hr width="40%" style="align:left">
<p>
<input type="submit" name="action" value="List enrolled students">
<input type="submit" name="action" value="List students who may enroll but may not have yet signed up">
<p>
Student Email: <input type="text" name="enstudent"> <input type="submit" name="action" value="Un-enroll student">
<input type="submit" name="action" value="Enroll student">
<input type="submit" name="action" value="Un-enroll ALL students">
<hr width="40%" style="align:left">
%if settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','') and instructor_access:
<%
rg = course.metadata.get('remote_gradebook',{})
%>
<p>Pull enrollment from remote gradebook</p>
<ul>
<li>Gradebook name: <font color="green">${rg.get('name','None defined!')}</font>
<li>Section: <input type="text" name="gradebook_section" size=40 value="${rg.get('section','')}"></li>
</ul>
<input type="submit" name="action" value="List sections available in remote gradebook">
<input type="submit" name="action" value="List students in section in remote gradebook">
<input type="submit" name="action" value="Overload enrollment list using remote gradebook">
<input type="submit" name="action" value="Merge enrollment list with remote gradebook">
<hr width="40%" style="align:left">
%endif
<p>Add students: enter emails, separated by returns or commas;</p>
<textarea rows="6" cols="70" name="enroll_multiple"></textarea>
<input type="submit" name="action" value="Enroll multiple students">
%endif
##-----------------------------------------------------------------------------
</form> </form>
##----------------------------------------------------------------------------- ##-----------------------------------------------------------------------------
%if modeflag.get('Psychometrics') is None: ##-----------------------------------------------------------------------------
%if datatable and modeflag.get('Psychometrics') is None:
<br/> <br/>
<br/> <br/>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment