Commit f7424eb8 by Diana Huang

Merge branch 'master' into tests/diana/update-oe-unit-tests

parents 585f1c41 3669ea5c
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
"/static/js/vendor/jquery.min.js", "/static/js/vendor/jquery.min.js",
"/static/js/vendor/json2.js", "/static/js/vendor/json2.js",
"/static/js/vendor/underscore-min.js", "/static/js/vendor/underscore-min.js",
"/static/js/vendor/backbone-min.js" "/static/js/vendor/backbone-min.js",
"/static/js/vendor/RequireJS.js"
] ]
} }
...@@ -87,7 +87,10 @@ $(document).ready(function() { ...@@ -87,7 +87,10 @@ $(document).ready(function() {
$('.unit').draggable({ $('.unit').draggable({
axis: 'y', axis: 'y',
handle: '.drag-handle', handle: '.drag-handle',
stack: '.unit', zIndex: 999,
start: initiateHesitate,
drag: checkHoverState,
stop: removeHesitate,
revert: "invalid" revert: "invalid"
}); });
...@@ -95,7 +98,10 @@ $(document).ready(function() { ...@@ -95,7 +98,10 @@ $(document).ready(function() {
$('.id-holder').draggable({ $('.id-holder').draggable({
axis: 'y', axis: 'y',
handle: '.section-item .drag-handle', handle: '.section-item .drag-handle',
stack: '.id-holder', zIndex: 999,
start: initiateHesitate,
drag: checkHoverState,
stop: removeHesitate,
revert: "invalid" revert: "invalid"
}); });
...@@ -179,10 +185,12 @@ function toggleSections(e) { ...@@ -179,10 +185,12 @@ function toggleSections(e) {
if($button.hasClass('is-activated')) { if($button.hasClass('is-activated')) {
$section.addClass('collapsed'); $section.addClass('collapsed');
$section.find('.expand-collapse-icon').removeClass('collapsed').addClass('expand'); // first child in order to avoid the icons on the subsection lists which are not in the first child
$section.find('header .expand-collapse-icon').removeClass('collapse').addClass('expand');
} else { } else {
$section.removeClass('collapsed'); $section.removeClass('collapsed');
$section.find('.expand-collapse-icon').removeClass('expand').addClass('collapse'); // first child in order to avoid the icons on the subsection lists which are not in the first child
$section.find('header .expand-collapse-icon').removeClass('expand').addClass('collapse');
} }
} }
...@@ -271,9 +279,67 @@ function removePolicyMetadata(e) { ...@@ -271,9 +279,67 @@ function removePolicyMetadata(e) {
saveSubsection() saveSubsection()
} }
CMS.HesitateEvent.toggleXpandHesitation = null;
function initiateHesitate(event, ui) {
CMS.HesitateEvent.toggleXpandHesitation = new CMS.HesitateEvent(expandSection, 'dragLeave', true);
$('.collapsed').on('dragEnter', CMS.HesitateEvent.toggleXpandHesitation, CMS.HesitateEvent.toggleXpandHesitation.trigger);
$('.collapsed').each(function() {
this.proportions = {width : this.offsetWidth, height : this.offsetHeight };
// reset b/c these were holding values from aborts
this.isover = false;
});
}
function checkHoverState(event, ui) {
// copied from jquery.ui.droppable.js $.ui.ddmanager.drag & other ui.intersect
var draggable = $(this).data("ui-draggable"),
x1 = (draggable.positionAbs || draggable.position.absolute).left + (draggable.helperProportions.width / 2),
y1 = (draggable.positionAbs || draggable.position.absolute).top + (draggable.helperProportions.height / 2);
$('.collapsed').each(function() {
// don't expand the thing being carried
if (ui.helper.is(this)) {
return;
}
$.extend(this, {offset : $(this).offset()});
var droppable = this,
l = droppable.offset.left,
r = l + droppable.proportions.width,
t = droppable.offset.top,
b = t + droppable.proportions.height;
if (l === r) {
// probably wrong values b/c invisible at the time of caching
droppable.proportions = { width : droppable.offsetWidth, height : droppable.offsetHeight };
r = l + droppable.proportions.width;
b = t + droppable.proportions.height;
}
// equivalent to the intersects test
var intersects = (l < x1 && // Right Half
x1 < r && // Left Half
t < y1 && // Bottom Half
y1 < b ), // Top Half
c = !intersects && this.isover ? "isout" : (intersects && !this.isover ? "isover" : null);
if(!c) {
return;
}
this[c] = true;
this[c === "isout" ? "isover" : "isout"] = false;
$(this).trigger(c === "isover" ? "dragEnter" : "dragLeave");
});
}
function removeHesitate(event, ui) {
$('.collapsed').off('dragEnter', CMS.HesitateEvent.toggleXpandHesitation.trigger);
CMS.HesitateEvent.toggleXpandHesitation = null;
}
function expandSection(event) { function expandSection(event) {
$(event.delegateTarget).removeClass('collapsed'); $(event.delegateTarget).removeClass('collapsed', 400);
$(event.delegateTarget).find('.expand-collapse-icon').removeClass('expand').addClass('collapse'); // don't descend to icon's on children (which aren't under first child) only to this element's icon
$(event.delegateTarget).children().first().find('.expand-collapse-icon').removeClass('expand', 400).addClass('collapse');
} }
function onUnitReordered(event, ui) { function onUnitReordered(event, ui) {
......
...@@ -18,33 +18,31 @@ CMS.HesitateEvent = function(executeOnTimeOut, cancelSelector, onlyOnce) { ...@@ -18,33 +18,31 @@ CMS.HesitateEvent = function(executeOnTimeOut, cancelSelector, onlyOnce) {
this.timeoutEventId = null; this.timeoutEventId = null;
this.originalEvent = null; this.originalEvent = null;
this.onlyOnce = (onlyOnce === true); this.onlyOnce = (onlyOnce === true);
} };
CMS.HesitateEvent.DURATION = 400; CMS.HesitateEvent.DURATION = 800;
CMS.HesitateEvent.prototype.trigger = function(event) { CMS.HesitateEvent.prototype.trigger = function(event) {
console.log('trigger'); if (event.data.timeoutEventId == null) {
if (this.timeoutEventId === null) { event.data.timeoutEventId = window.setTimeout(
this.timeoutEventId = window.setTimeout(this.fireEvent, CMS.HesitateEvent.DURATION); function() { event.data.fireEvent(event); },
this.originalEvent = event; CMS.HesitateEvent.DURATION);
// is it wrong to bind to the below v $(event.currentTarget)? event.data.originalEvent = event;
$(this.originalEvent.delegateTarget).on(this.cancelSelector, this.untrigger); $(event.data.originalEvent.delegateTarget).on(event.data.cancelSelector, event.data, event.data.untrigger);
} }
} };
CMS.HesitateEvent.prototype.fireEvent = function(event) { CMS.HesitateEvent.prototype.fireEvent = function(event) {
console.log('fire'); event.data.timeoutEventId = null;
this.timeoutEventId = null; $(event.data.originalEvent.delegateTarget).off(event.data.cancelSelector, event.data.untrigger);
$(this.originalEvent.delegateTarget).off(this.cancelSelector, this.untrigger); if (event.data.onlyOnce) $(event.data.originalEvent.delegateTarget).off(event.data.originalEvent.type, event.data.trigger);
if (this.onlyOnce) $(this.originalEvent.delegateTarget).off(this.originalEvent.type, this.trigger); event.data.executeOnTimeOut(event.data.originalEvent);
this.executeOnTimeOut(this.originalEvent); };
}
CMS.HesitateEvent.prototype.untrigger = function(event) { CMS.HesitateEvent.prototype.untrigger = function(event) {
console.log('untrigger'); if (event.data.timeoutEventId) {
if (this.timeoutEventId) { window.clearTimeout(event.data.timeoutEventId);
window.clearTimeout(this.timeoutEventId); $(event.data.originalEvent.delegateTarget).off(event.data.cancelSelector, event.data.untrigger);
$(this.originalEvent.delegateTarget).off(this.cancelSelector, this.untrigger);
} }
this.timeoutEventId = null; event.data.timeoutEventId = null;
} };
\ No newline at end of file \ No newline at end of file
...@@ -10,6 +10,25 @@ from course_groups.cohorts import (get_cohort, get_course_cohorts, ...@@ -10,6 +10,25 @@ from course_groups.cohorts import (get_cohort, get_course_cohorts,
from xmodule.modulestore.django import modulestore, _MODULESTORES from xmodule.modulestore.django import modulestore, _MODULESTORES
# NOTE: running this with the lms.envs.test config works without
# manually overriding the modulestore. However, running with
# cms.envs.test doesn't.
def xml_store_config(data_dir):
return {
'default': {
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
'OPTIONS': {
'data_dir': data_dir,
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
}
}
}
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestCohorts(django.test.TestCase): class TestCohorts(django.test.TestCase):
......
from django.core.management.base import BaseCommand, CommandError
import os
from optparse import make_option
from student.models import UserProfile
import csv
class Command(BaseCommand):
help = """
Sets or gets certificate restrictions for users
from embargoed countries. (allow_certificate in
userprofile)
CSV should be comma delimited with double quoted entries.
$ ... cert_restriction --import path/to/userlist.csv
Export a list of students who have "allow_certificate" in
userprofile set to True
$ ... cert_restriction --output path/to/export.csv
Enable a single user so she is not on the restricted list
$ ... cert_restriction -e user
Disable a single user so she is on the restricted list
$ ... cert_restriction -d user
"""
option_list = BaseCommand.option_list + (
make_option('-i', '--import',
metavar='IMPORT_FILE',
dest='import',
default=False,
help='csv file to import, comma delimitted file with '
'double-quoted entries'),
make_option('-o', '--output',
metavar='EXPORT_FILE',
dest='output',
default=False,
help='csv file to export'),
make_option('-e', '--enable',
metavar='STUDENT',
dest='enable',
default=False,
help="enable a single student's certificate"),
make_option('-d', '--disable',
metavar='STUDENT',
dest='disable',
default=False,
help="disable a single student's certificate")
)
def handle(self, *args, **options):
if options['output']:
if os.path.exists(options['output']):
raise CommandError("File {0} already exists".format(
options['output']))
disabled_users = UserProfile.objects.filter(
allow_certificate=False)
with open(options['output'], 'w') as csvfile:
csvwriter = csv.writer(csvfile, delimiter=',', quotechar='"',
quoting=csv.QUOTE_MINIMAL)
for user in disabled_users:
csvwriter.writerow([user.user.username])
elif options['import']:
if not os.path.exists(options['import']):
raise CommandError("File {0} does not exist".format(
options['import']))
print "Importing students from {0}".format(options['import'])
students = None
with open(options['import']) as csvfile:
student_list = csv.reader(csvfile, delimiter=',',
quotechar='"')
students = [student[0] for student in student_list]
if not students:
raise CommandError(
"Unable to read student data from {0}".format(
options['import']))
UserProfile.objects.filter(user__username__in=students).update(
allow_certificate=False)
elif options['enable']:
print "Enabling {0} for certificate download".format(
options['enable'])
cert_allow = UserProfile.objects.get(
user__username=options['enable'])
cert_allow.allow_certificate = True
cert_allow.save()
elif options['disable']:
print "Disabling {0} for certificate download".format(
options['disable'])
cert_allow = UserProfile.objects.get(
user__username=options['disable'])
cert_allow.allow_certificate = False
cert_allow.save()
from optparse import make_option
from json import dump
from django.core.management.base import BaseCommand, CommandError
from student.models import TestCenterRegistration
class Command(BaseCommand):
args = '<output JSON file>'
help = """
Dump information as JSON from TestCenterRegistration tables, including username and status.
"""
option_list = BaseCommand.option_list + (
make_option('--course_id',
action='store',
dest='course_id',
help='Specify a particular course.'),
make_option('--exam_series_code',
action='store',
dest='exam_series_code',
default=None,
help='Specify a particular exam, using the Pearson code'),
make_option('--accommodation_pending',
action='store_true',
dest='accommodation_pending',
default=False,
),
)
def handle(self, *args, **options):
if len(args) < 1:
raise CommandError("Missing single argument: output JSON file")
# get output location:
outputfile = args[0]
# construct the query object to dump:
registrations = TestCenterRegistration.objects.all()
if 'course_id' in options and options['course_id']:
registrations = registrations.filter(course_id=options['course_id'])
if 'exam_series_code' in options and options['exam_series_code']:
registrations = registrations.filter(exam_series_code=options['exam_series_code'])
# collect output:
output = []
for registration in registrations:
if 'accommodation_pending' in options and options['accommodation_pending'] and not registration.accommodation_is_pending:
continue
record = {'username' : registration.testcenter_user.user.username,
'email' : registration.testcenter_user.email,
'first_name' : registration.testcenter_user.first_name,
'last_name' : registration.testcenter_user.last_name,
'client_candidate_id' : registration.client_candidate_id,
'client_authorization_id' : registration.client_authorization_id,
'course_id' : registration.course_id,
'exam_series_code' : registration.exam_series_code,
'accommodation_request' : registration.accommodation_request,
'accommodation_code' : registration.accommodation_code,
'registration_status' : registration.registration_status(),
'demographics_status' : registration.demographics_status(),
'accommodation_status' : registration.accommodation_status(),
}
if len(registration.upload_error_message) > 0:
record['registration_error'] = registration.upload_error_message
if registration.needs_uploading:
record['needs_uploading'] = True
output.append(record)
# dump output:
with open(outputfile, 'w') as outfile:
dump(output, outfile)
...@@ -65,7 +65,7 @@ class Command(BaseCommand): ...@@ -65,7 +65,7 @@ class Command(BaseCommand):
else: else:
try: try:
registration = TestCenterRegistration.objects.get(client_authorization_id=client_authorization_id) registration = TestCenterRegistration.objects.get(client_authorization_id=client_authorization_id)
Command.datadog_error("Found authorization record for user {}".format(registration.testcenter_user.user.username), eacfile) Command.datadog_error("Found authorization record for user {}".format(registration.testcenter_user.user.username), eacfile.name)
# now update the record: # now update the record:
registration.upload_status = row['Status'] registration.upload_status = row['Status']
registration.upload_error_message = row['Message'] registration.upload_error_message = row['Message']
......
...@@ -179,7 +179,8 @@ class Command(BaseCommand): ...@@ -179,7 +179,8 @@ class Command(BaseCommand):
if (len(form.errors) > 0): if (len(form.errors) > 0):
print "Field Form errors encountered:" print "Field Form errors encountered:"
for fielderror in form.errors: for fielderror in form.errors:
print "Field Form Error: %s" % fielderror for msg in form.errors[fielderror]:
print "Field Form Error: {} -- {}".format(fielderror, msg)
if (len(form.non_field_errors()) > 0): if (len(form.non_field_errors()) > 0):
print "Non-field Form errors encountered:" print "Non-field Form errors encountered:"
for nonfielderror in form.non_field_errors: for nonfielderror in form.non_field_errors:
......
# -*- 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 field 'UserProfile.allow_certificate'
db.add_column('auth_userprofile', 'allow_certificate',
self.gf('django.db.models.fields.BooleanField')(default=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'UserProfile.allow_certificate'
db.delete_column('auth_userprofile', 'allow_certificate')
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.testcenterregistration': {
'Meta': {'object_name': 'TestCenterRegistration'},
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}),
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': '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', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': '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'}),
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': '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'}),
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': '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'"},
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'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
...@@ -90,6 +90,7 @@ class UserProfile(models.Model): ...@@ -90,6 +90,7 @@ class UserProfile(models.Model):
) )
mailing_address = models.TextField(blank=True, null=True) mailing_address = models.TextField(blank=True, null=True)
goals = models.TextField(blank=True, null=True) goals = models.TextField(blank=True, null=True)
allow_certificate = models.BooleanField(default=1)
def get_meta(self): def get_meta(self):
js_str = self.meta js_str = self.meta
...@@ -255,9 +256,9 @@ class TestCenterUserForm(ModelForm): ...@@ -255,9 +256,9 @@ class TestCenterUserForm(ModelForm):
def clean_country(self): def clean_country(self):
code = self.cleaned_data['country'] code = self.cleaned_data['country']
if code and len(code) != 3: if code and (len(code) != 3 or not code.isalpha()):
raise forms.ValidationError(u'Must be three characters (ISO 3166-1): e.g. USA, CAN, MNG') raise forms.ValidationError(u'Must be three characters (ISO 3166-1): e.g. USA, CAN, MNG')
return code return code.upper()
def clean(self): def clean(self):
def _can_encode_as_latin(fieldvalue): def _can_encode_as_latin(fieldvalue):
...@@ -387,6 +388,12 @@ class TestCenterRegistration(models.Model): ...@@ -387,6 +388,12 @@ class TestCenterRegistration(models.Model):
return 'Update' return 'Update'
elif self.uploaded_at is None: elif self.uploaded_at is None:
return 'Add' return 'Add'
elif self.registration_is_rejected:
# Assume that if the registration was rejected before,
# it is more likely this is the (first) correction
# than a second correction in flight before the first was
# processed.
return 'Add'
else: else:
# TODO: decide what to send when we have uploaded an initial version, # TODO: decide what to send when we have uploaded an initial version,
# but have not received confirmation back from that upload. If the # but have not received confirmation back from that upload. If the
...@@ -400,7 +407,8 @@ class TestCenterRegistration(models.Model): ...@@ -400,7 +407,8 @@ class TestCenterRegistration(models.Model):
@property @property
def exam_authorization_count(self): def exam_authorization_count(self):
# TODO: figure out if this should really go in the database (with a default value). # Someday this could go in the database (with a default value). But at present,
# we do not expect anyone to be authorized to take an exam more than once.
return 1 return 1
@property @property
...@@ -499,6 +507,33 @@ class TestCenterRegistration(models.Model): ...@@ -499,6 +507,33 @@ class TestCenterRegistration(models.Model):
def registration_signup_url(self): def registration_signup_url(self):
return settings.PEARSONVUE_SIGNINPAGE_URL return settings.PEARSONVUE_SIGNINPAGE_URL
def demographics_status(self):
if self.demographics_is_accepted:
return "Accepted"
elif self.demographics_is_rejected:
return "Rejected"
else:
return "Pending"
def accommodation_status(self):
if self.accommodation_is_skipped:
return "Skipped"
elif self.accommodation_is_accepted:
return "Accepted"
elif self.accommodation_is_rejected:
return "Rejected"
else:
return "Pending"
def registration_status(self):
if self.registration_is_accepted:
return "Accepted"
elif self.registration_is_rejected:
return "Rejected"
else:
return "Pending"
class TestCenterRegistrationForm(ModelForm): class TestCenterRegistrationForm(ModelForm):
class Meta: class Meta:
model = TestCenterRegistration model = TestCenterRegistration
...@@ -518,7 +553,15 @@ class TestCenterRegistrationForm(ModelForm): ...@@ -518,7 +553,15 @@ class TestCenterRegistrationForm(ModelForm):
registration.save() registration.save()
log.info("Updated registration information for user's test center exam registration: username \"{}\" course \"{}\", examcode \"{}\"".format(registration.testcenter_user.user.username, registration.course_id, registration.exam_series_code)) log.info("Updated registration information for user's test center exam registration: username \"{}\" course \"{}\", examcode \"{}\"".format(registration.testcenter_user.user.username, registration.course_id, registration.exam_series_code))
# TODO: add validation code for values added to accommodation_code field. def clean_accommodation_code(self):
code = self.cleaned_data['accommodation_code']
if code:
code = code.upper()
codes = code.split('*')
for codeval in codes:
if codeval not in ACCOMMODATION_CODE_DICT:
raise forms.ValidationError(u'Invalid accommodation code specified: "{}"'.format(codeval))
return code
......
...@@ -135,7 +135,7 @@ def cert_info(user, course): ...@@ -135,7 +135,7 @@ def cert_info(user, course):
Get the certificate info needed to render the dashboard section for the given Get the certificate info needed to render the dashboard section for the given
student and course. Returns a dictionary with keys: student and course. Returns a dictionary with keys:
'status': one of 'generating', 'ready', 'notpassing', 'processing' 'status': one of 'generating', 'ready', 'notpassing', 'processing', 'restricted'
'show_download_url': bool 'show_download_url': bool
'download_url': url, only present if show_download_url is True 'download_url': url, only present if show_download_url is True
'show_disabled_download_button': bool -- true if state is 'generating' 'show_disabled_download_button': bool -- true if state is 'generating'
...@@ -168,6 +168,7 @@ def _cert_info(user, course, cert_status): ...@@ -168,6 +168,7 @@ def _cert_info(user, course, cert_status):
CertificateStatuses.regenerating: 'generating', CertificateStatuses.regenerating: 'generating',
CertificateStatuses.downloadable: 'ready', CertificateStatuses.downloadable: 'ready',
CertificateStatuses.notpassing: 'notpassing', CertificateStatuses.notpassing: 'notpassing',
CertificateStatuses.restricted: 'restricted',
} }
status = template_state.get(cert_status['status'], default_status) status = template_state.get(cert_status['status'], default_status)
...@@ -176,7 +177,7 @@ def _cert_info(user, course, cert_status): ...@@ -176,7 +177,7 @@ def _cert_info(user, course, cert_status):
'show_download_url': status == 'ready', 'show_download_url': status == 'ready',
'show_disabled_download_button': status == 'generating',} 'show_disabled_download_button': status == 'generating',}
if (status in ('generating', 'ready', 'notpassing') and if (status in ('generating', 'ready', 'notpassing', 'restricted') and
course.end_of_course_survey_url is not None): course.end_of_course_survey_url is not None):
d.update({ d.update({
'show_survey_button': True, 'show_survey_button': True,
...@@ -192,7 +193,7 @@ def _cert_info(user, course, cert_status): ...@@ -192,7 +193,7 @@ def _cert_info(user, course, cert_status):
else: else:
d['download_url'] = cert_status['download_url'] d['download_url'] = cert_status['download_url']
if status in ('generating', 'ready', 'notpassing'): if status in ('generating', 'ready', 'notpassing', 'restricted'):
if 'grade' not in cert_status: if 'grade' not in cert_status:
# Note: as of 11/20/2012, we know there are students in this state-- cs169.1x, # Note: as of 11/20/2012, we know there are students in this state-- cs169.1x,
# who need to be regraded (we weren't tracking 'notpassing' at first). # who need to be regraded (we weren't tracking 'notpassing' at first).
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
<script type="text/javascript" src="<%= common_js_root %>/vendor/mathjax-MathJax-c9db6ac/MathJax.js"></script> <script type="text/javascript" src="<%= common_js_root %>/vendor/mathjax-MathJax-c9db6ac/MathJax.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/jquery.tinymce.js"></script> <script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/jquery.tinymce.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/tiny_mce.js"></script> <script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/tiny_mce.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/RequireJS.js"></script>
<script type="text/javascript"> <script type="text/javascript">
AjaxPrefix.addAjaxPrefix(jQuery, function() { AjaxPrefix.addAjaxPrefix(jQuery, function() {
......
...@@ -247,7 +247,7 @@ def remap_namespace(module, target_location_namespace): ...@@ -247,7 +247,7 @@ def remap_namespace(module, target_location_namespace):
return module return module
def validate_category_hierarcy(module_store, course_id, parent_category, expected_child_category): def validate_category_hierarchy(module_store, course_id, parent_category, expected_child_category):
err_cnt = 0 err_cnt = 0
parents = [] parents = []
...@@ -324,11 +324,18 @@ def perform_xlint(data_dir, course_dirs, ...@@ -324,11 +324,18 @@ def perform_xlint(data_dir, course_dirs,
for course_id in module_store.modules.keys(): for course_id in module_store.modules.keys():
# constrain that courses only have 'chapter' children # constrain that courses only have 'chapter' children
err_cnt += validate_category_hierarcy(module_store, course_id, "course", "chapter") err_cnt += validate_category_hierarchy(module_store, course_id, "course", "chapter")
# constrain that chapters only have 'sequentials' # constrain that chapters only have 'sequentials'
err_cnt += validate_category_hierarcy(module_store, course_id, "chapter", "sequential") err_cnt += validate_category_hierarchy(module_store, course_id, "chapter", "sequential")
# constrain that sequentials only have 'verticals' # constrain that sequentials only have 'verticals'
err_cnt += validate_category_hierarcy(module_store, course_id, "sequential", "vertical") err_cnt += validate_category_hierarchy(module_store, course_id, "sequential", "vertical")
# check for a presence of a course marketing video
location_elements = course_id.split('/')
if Location(['i4x', location_elements[0], location_elements[1], 'about', 'video', None]) not in module_store.modules[course_id]:
print "WARN: Missing course marketing video. It is recommended that every course have a marketing video."
warn_cnt += 1
print "\n\n------------------------------------------\nVALIDATION SUMMARY: {0} Errors {1} Warnings\n".format(err_cnt, warn_cnt) print "\n\n------------------------------------------\nVALIDATION SUMMARY: {0} Errors {1} Warnings\n".format(err_cnt, warn_cnt)
......
...@@ -83,10 +83,11 @@ var CohortManager = (function ($) { ...@@ -83,10 +83,11 @@ var CohortManager = (function ($) {
cohort_id = el.data('id'); cohort_id = el.data('id');
state = state_detail; state = state_detail;
render(); render();
return false;
} }
function add_to_cohorts_list(item) { function add_to_cohorts_list(item) {
var li = $('<li><a></a></li>'); var li = $('<li><a href="#"></a></li>');
$("a", li).text(item.name) $("a", li).text(item.name)
.data('href', url + '/' + item.id) .data('href', url + '/' + item.id)
.addClass('link') .addClass('link')
......
...@@ -209,6 +209,14 @@ $.widget("ui.draggable", $.ui.mouse, { ...@@ -209,6 +209,14 @@ $.widget("ui.draggable", $.ui.mouse, {
// computation of pageY or scrollTop() or caching of scrollTop at same state as pageY // computation of pageY or scrollTop() or caching of scrollTop at same state as pageY
// btw: known bug in jqueryui http://bugs.jqueryui.com/ticket/5718 // btw: known bug in jqueryui http://bugs.jqueryui.com/ticket/5718
if (this.scrollParent.is(document) && this.cssPosition === 'relative') { if (this.scrollParent.is(document) && this.cssPosition === 'relative') {
// need to catch the original parent having been shoved down during drag (perhaps by
// events)
// update cached originals if it shifted
if (this.offset && this.offset.parent && this.offset.parent.top !== this._getParentOffset().top) {
var deltaY = this.offset.parent.top - this._getParentOffset().top;
this.offset.parent.top = this._getParentOffset().top;
this.originalPageY -= deltaY;
}
this.helper[0].style.top = (event.pageY - this.originalPageY) +"px"; this.helper[0].style.top = (event.pageY - this.originalPageY) +"px";
} }
else this.helper[0].style.top = this.position.top+"px"; else this.helper[0].style.top = this.position.top+"px";
......
from django.core.management.base import BaseCommand, CommandError
from optparse import make_option
from certificates.models import CertificateWhitelist
from django.contrib.auth.models import User
class Command(BaseCommand):
help = """
Sets or gets the certificate whitelist for a given
user/course
Add a user to the whitelist for a course
$ ... cert_whitelist --add joe -c "MITx/6.002x/2012_Fall"
Remove a user from the whitelist for a course
$ ... cert_whitelist --del joe -c "MITx/6.002x/2012_Fall"
Print out who is whitelisted for a course
$ ... cert_whitelist -c "MITx/6.002x/2012_Fall"
"""
option_list = BaseCommand.option_list + (
make_option('-a', '--add',
metavar='USER',
dest='add',
default=False,
help='user to add to the certificate whitelist'),
make_option('-d', '--del',
metavar='USER',
dest='del',
default=False,
help='user to remove from the certificate whitelist'),
make_option('-c', '--course-id',
metavar='COURSE_ID',
dest='course_id',
default=False,
help="course id to query"),
)
def handle(self, *args, **options):
course_id = options['course_id']
if not course_id:
raise CommandError("You must specify a course-id")
if options['add'] and options['del']:
raise CommandError("Either remove or add a user, not both")
if options['add'] or options['del']:
user_str = options['add'] or options['del']
if '@' in user_str:
user = User.objects.get(email=user_str)
else:
user = User.objects.get(username=user_str)
cert_whitelist, created = \
CertificateWhitelist.objects.get_or_create(
user=user, course_id=course_id)
if options['add']:
cert_whitelist.whitelist = True
elif options['del']:
cert_whitelist.whitelist = False
cert_whitelist.save()
whitelist = CertificateWhitelist.objects.all()
print "User whitelist for course {0}:\n{1}".format(course_id,
'\n'.join(["{0} {1} {2}".format(
u.user.username, u.user.email, u.whitelist)
for u in whitelist]))
# -*- 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 'CertificateWhitelist'
db.create_table('certificates_certificatewhitelist', (
('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')(default='', max_length=255, blank=True)),
('whitelist', self.gf('django.db.models.fields.BooleanField')(default=False)),
))
db.send_create_signal('certificates', ['CertificateWhitelist'])
def backwards(self, orm):
# Deleting model 'CertificateWhitelist'
db.delete_table('certificates_certificatewhitelist')
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'})
},
'certificates.certificatewhitelist': {
'Meta': {'object_name': 'CertificateWhitelist'},
'course_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
},
'certificates.generatedcertificate': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'},
'course_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}),
'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}),
'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}),
'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}),
'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}),
'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'})
},
'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'})
}
}
complete_apps = ['certificates']
\ No newline at end of file
...@@ -35,6 +35,19 @@ State diagram: ...@@ -35,6 +35,19 @@ State diagram:
v v v v v v
[downloadable] [downloadable] [deleted] [downloadable] [downloadable] [deleted]
Eligibility:
Students are eligible for a certificate if they pass the course
with the following exceptions:
If the student has allow_certificate set to False in the student profile
he will never be issued a certificate.
If the user and course is present in the certificate whitelist table
then the student will be issued a certificate regardless of his grade,
unless he has allow_certificate set to False.
""" """
...@@ -46,8 +59,20 @@ class CertificateStatuses(object): ...@@ -46,8 +59,20 @@ class CertificateStatuses(object):
deleted = 'deleted' deleted = 'deleted'
downloadable = 'downloadable' downloadable = 'downloadable'
notpassing = 'notpassing' notpassing = 'notpassing'
restricted = 'restricted'
error = 'error' error = 'error'
class CertificateWhitelist(models.Model):
"""
Tracks students who are whitelisted, all users
in this table will always qualify for a certificate
regardless of their grade unless they are on the
embargoed country restriction list
(allow_certificate set to False in userprofile).
"""
user = models.ForeignKey(User)
course_id = models.CharField(max_length=255, blank=True, default='')
whitelist = models.BooleanField(default=0)
class GeneratedCertificate(models.Model): class GeneratedCertificate(models.Model):
user = models.ForeignKey(User) user = models.ForeignKey(User)
...@@ -87,6 +112,10 @@ def certificate_status_for_student(student, course_id): ...@@ -87,6 +112,10 @@ def certificate_status_for_student(student, course_id):
deleted - The certificate has been deleted. deleted - The certificate has been deleted.
downloadable - The certificate is available for download. downloadable - The certificate is available for download.
notpassing - The student was graded but is not passing notpassing - The student was graded but is not passing
restricted - The student is on the restricted embargo list and
should not be issued a certificate. This will
be set if allow_certificate is set to False in
the userprofile table
If the status is "downloadable", the dictionary also contains If the status is "downloadable", the dictionary also contains
"download_url". "download_url".
......
from certificates.models import GeneratedCertificate from certificates.models import GeneratedCertificate
from certificates.models import certificate_status_for_student from certificates.models import certificate_status_for_student
from certificates.models import CertificateStatuses as status from certificates.models import CertificateStatuses as status
from certificates.models import CertificateWhitelist
from courseware import grades, courses from courseware import grades, courses
from django.test.client import RequestFactory from django.test.client import RequestFactory
...@@ -71,6 +72,8 @@ class XQueueCertInterface(object): ...@@ -71,6 +72,8 @@ class XQueueCertInterface(object):
settings.XQUEUE_INTERFACE['django_auth'], settings.XQUEUE_INTERFACE['django_auth'],
requests_auth, requests_auth,
) )
self.whitelist = CertificateWhitelist.objects.all()
self.restricted = UserProfile.objects.filter(allow_certificate=False)
def regen_cert(self, student, course_id): def regen_cert(self, student, course_id):
""" """
...@@ -93,49 +96,7 @@ class XQueueCertInterface(object): ...@@ -93,49 +96,7 @@ class XQueueCertInterface(object):
""" """
VALID_STATUSES = [status.error, status.downloadable] raise NotImplementedError
cert_status = certificate_status_for_student(
student, course_id)['status']
if cert_status in VALID_STATUSES:
# grade the student
course = courses.get_course_by_id(course_id)
grade = grades.grade(student, self.request, course)
profile = UserProfile.objects.get(user=student)
try:
cert = GeneratedCertificate.objects.get(
user=student, course_id=course_id)
except GeneratedCertificate.DoesNotExist:
logger.critical("Attempting to regenerate a certificate"
"for a user that doesn't have one")
raise
if grade['grade'] is not None:
cert.status = status.regenerating
cert.name = profile.name
contents = {
'action': 'regen',
'delete_verify_uuid': cert.verify_uuid,
'delete_download_uuid': cert.download_uuid,
'username': cert.user.username,
'course_id': cert.course_id,
'name': profile.name,
}
key = cert.key
self._send_to_xqueue(contents, key)
cert.save()
else:
cert.status = status.notpassing
cert.name = profile.name
cert.save()
return cert_status
def del_cert(self, student, course_id): def del_cert(self, student, course_id):
...@@ -152,34 +113,7 @@ class XQueueCertInterface(object): ...@@ -152,34 +113,7 @@ class XQueueCertInterface(object):
""" """
VALID_STATUSES = [status.error, status.downloadable] raise NotImplementedError
cert_status = certificate_status_for_student(
student, course_id)['status']
if cert_status in VALID_STATUSES:
try:
cert = GeneratedCertificate.objects.get(
user=student, course_id=course_id)
except GeneratedCertificate.DoesNotExist:
logger.warning("Attempting to delete a certificate"
"for a user that doesn't have one")
raise
cert.status = status.deleting
contents = {
'action': 'delete',
'delete_verify_uuid': cert.verify_uuid,
'delete_download_uuid': cert.download_uuid,
'username': cert.user.username,
}
key = cert.key
self._send_to_xqueue(contents, key)
cert.save()
return cert_status
def add_cert(self, student, course_id): def add_cert(self, student, course_id):
""" """
...@@ -189,13 +123,17 @@ class XQueueCertInterface(object): ...@@ -189,13 +123,17 @@ class XQueueCertInterface(object):
course_id - courseenrollment.course_id (string) course_id - courseenrollment.course_id (string)
Request a new certificate for a student. Request a new certificate for a student.
Will change the certificate status to 'deleting'. Will change the certificate status to 'generating'.
Certificate must be in the 'unavailable', 'error', Certificate must be in the 'unavailable', 'error',
'deleted' or 'generating' state. 'deleted' or 'generating' state.
If a student has a passing grade a request will made If a student has a passing grade or is in the whitelist
for a new cert table for the course a request will made for a new cert.
If a student has allow_certificate set to False in the
userprofile table the status will change to 'restricted'
If a student does not have a passing grade the status If a student does not have a passing grade the status
will change to status.notpassing will change to status.notpassing
...@@ -214,29 +152,40 @@ class XQueueCertInterface(object): ...@@ -214,29 +152,40 @@ class XQueueCertInterface(object):
if cert_status in VALID_STATUSES: if cert_status in VALID_STATUSES:
# grade the student # grade the student
course = courses.get_course_by_id(course_id) course = courses.get_course_by_id(course_id)
grade = grades.grade(student, self.request, course)
profile = UserProfile.objects.get(user=student) profile = UserProfile.objects.get(user=student)
cert, created = GeneratedCertificate.objects.get_or_create( cert, created = GeneratedCertificate.objects.get_or_create(
user=student, course_id=course_id) user=student, course_id=course_id)
if grade['grade'] is not None: grade = grades.grade(student, self.request, course)
cert_status = status.generating is_whitelisted = self.whitelist.filter(
user=student, course_id=course_id, whitelist=True).exists()
if is_whitelisted or grade['grade'] is not None:
key = make_hashkey(random.random()) key = make_hashkey(random.random())
cert.status = cert_status
cert.grade = grade['percent'] cert.grade = grade['percent']
cert.user = student cert.user = student
cert.course_id = course_id cert.course_id = course_id
cert.key = key cert.key = key
cert.name = profile.name cert.name = profile.name
# check to see whether the student is on the
# the embargoed country restricted list
# otherwise, put a new certificate request
# on the queue
if self.restricted.filter(user=student).exists():
cert.status = status.restricted
else:
contents = { contents = {
'action': 'create', 'action': 'create',
'username': student.username, 'username': student.username,
'course_id': course_id, 'course_id': course_id,
'name': profile.name, 'name': profile.name,
} }
cert.status = status.generating
self._send_to_xqueue(contents, key) self._send_to_xqueue(contents, key)
cert.save() cert.save()
else: else:
......
...@@ -61,7 +61,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG ...@@ -61,7 +61,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
#if the course-user is cohorted, then add the group id #if the course-user is cohorted, then add the group id
group_id = get_cohort_id(user,course_id) group_id = get_cohort_id(user, course_id)
if group_id: if group_id:
default_query_params["group_id"] = group_id default_query_params["group_id"] = group_id
......
...@@ -273,12 +273,16 @@ ...@@ -273,12 +273,16 @@
% if cert_status['status'] == 'processing': % if cert_status['status'] == 'processing':
<p class="message-copy">Final course details are being wrapped up at <p class="message-copy">Final course details are being wrapped up at
this time. Your final standing will be available shortly.</p> this time. Your final standing will be available shortly.</p>
% elif cert_status['status'] in ('generating', 'ready', 'notpassing'): % elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'):
<p class="message-copy">Your final grade: <p class="message-copy">Your final grade:
<span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>. <span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>.
% if cert_status['status'] == 'notpassing': % if cert_status['status'] == 'notpassing':
Grade required for a certificate: <span class="grade-value"> Grade required for a certificate: <span class="grade-value">
${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}</span>. ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}</span>.
% elif cert_status['status'] == 'restricted':
<p class="message-copy">
Your certificate is being held while we seek confirmation that the issuance of your certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting <a class="contact-link" href="mailto:info@edx.org">info@edx.org</a>.
</p>
% endif % endif
</p> </p>
% endif % endif
......
...@@ -84,7 +84,7 @@ ...@@ -84,7 +84,7 @@
<nav class="categories"> <nav class="categories">
<a href="#organization">Organization</a> <a href="#organization">Organization</a>
<a href="#students">Students</a> <a href="${reverse('help_edx')}">Students</a>
<a href="#technology-platform">Technology Platform</a> <a href="#technology-platform">Technology Platform</a>
</nav> </nav>
</section> </section>
......
...@@ -54,12 +54,6 @@ ...@@ -54,12 +54,6 @@
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">How can I help edX?</h3>
<div class ="answer" id="edx_basics_faq_answer_6">
<p>You may not realize it, but just by taking a course you are helping edX. That’s because the edX platform has been specifically designed to not only teach, but also gather data about learning. EdX will utilize this data to find out how to improve education online and on-campus.</p>
</div>
</article>
<article class="response">
<h3 class="question">When does my course start and/or finish?</h3> <h3 class="question">When does my course start and/or finish?</h3>
<div class ="answer" id="edx_basics_faq_answer_7"> <div class ="answer" id="edx_basics_faq_answer_7">
<p>You can find the start and stop dates for each course on each course description page.</p> <p>You can find the start and stop dates for each course on each course description page.</p>
...@@ -78,7 +72,7 @@ ...@@ -78,7 +72,7 @@
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">What happens if I have to quit a course, are there any penalties, will I be able to take another course in the future?</h3> <h3 class="question">What happens if I have to quit a course? Are there any penalties? Will I be able to take another course in the future?</h3>
<div class ="answer" id="edx_basics_faq_answer_10"> <div class ="answer" id="edx_basics_faq_answer_10">
<p>You may unregister from an edX course at anytime, there are absolutely no penalties associated with incomplete edX studies, and you may register for the same course (provided we are still offering it) at a later time.</p> <p>You may unregister from an edX course at anytime, there are absolutely no penalties associated with incomplete edX studies, and you may register for the same course (provided we are still offering it) at a later time.</p>
</div> </div>
...@@ -147,85 +141,94 @@ ...@@ -147,85 +141,94 @@
<p>The Discussion Forums are the best place to reach out to the edX teaching team for your class, and you don’t have to wait in line or rearrange your schedule to fit your professor’s – just post your questions. The response isn’t always immediate, but it’s usually pretty darned quick.</p> <p>The Discussion Forums are the best place to reach out to the edX teaching team for your class, and you don’t have to wait in line or rearrange your schedule to fit your professor’s – just post your questions. The response isn’t always immediate, but it’s usually pretty darned quick.</p>
</div> </div>
</article> </article>
</section>
<section id="getting_help_faq" class="category"> <article class="response">
<h2>Getting Help</h2> <h3 class="question">Getting help.</h3>
<div class="answer" id="classes_faq_answer_10">
<p>You have a vibrant, global community of fellow online learners available 24-7 to help with the course within the framework of the Honor Code, as well as support from the TAs who monitor the course. Take a look at the course’s Discussion Forum where you can review questions, answers and comments from fellow online learners, as well as post a question.</p>
</div>
</article>
<article class="response"> <article class="response">
<h3 class="question">Can I re-take a course?</h3> <h3 class="question">Can I re-take a course?</h3>
<div class ="answer" id="getting_help_faq_answer_0"> <div class ="answer" id="classes_faq_answer_11">
<p>Good news: there are unlimited "mulligans" in edX. You may re-take edX courses as often as you wish. Your performance in any particular offering of a course will not effect your standing in future offerings of any edX course, including future offerings of the same course.</p> <p>Good news: there are unlimited "mulligans" in edX. You may re-take edX courses as often as you wish. Your performance in any particular offering of a course will not effect your standing in future offerings of any edX course, including future offerings of the same course.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">Enrollment for a course that I am interested in is open, but the course has already started. Can I still enroll?</h3> <h3 class="question">Enrollment for a course that I am interested in is open, but the course has already started. Can I still enroll?</h3>
<div class ="answer" id="getting_help_faq_answer_1"> <div class ="answer" id="classes_faq_answer_12">
<p>Yes, but you will not be able to turn in any assignments or exams that have already been due. If it is early in the course, you might still be able to earn enough points for a certificate, but you will have to check with the course in question in order to find out more.</p> <p>Yes, but you will not be able to turn in any assignments or exams that have already been due. If it is early in the course, you might still be able to earn enough points for a certificate, but you will have to check with the course in question in order to find out more.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">Is there an exam at the end?</h3> <h3 class="question">Is there an exam at the end?</h3>
<div class ="answer" id="getting_help_faq_answer_2"> <div class ="answer" id="classes_faq_answer_13">
<p>Different courses have slightly different structures. Please check the course material description to see if there is a final exam or final project.</p> <p>Different courses have slightly different structures. Please check the course material description to see if there is a final exam or final project.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">Will the same courses be offered again in the future?</h3> <h3 class="question">Will the same courses be offered again in the future?</h3>
<div class ="answer" id="getting_help_faq_answer_3"> <div class ="answer" id="classes_faq_answer_14">
<p>Existing edX courses will be re-offered, and more courses added.</p> <p>Existing edX courses will be re-offered, and more courses added.</p>
</div> </div>
</article> </article>
</section>
<section id="certificates_and_credits_faq" class="category">
<h2>Certificates & Credits</h2>
<article class="response"> <article class="response">
<h3 class="question">Will I get a certificate for taking an edX course?</h3> <h3 class="question">Will I get a certificate for taking an edX course?</h3>
<div class ="answer" id="getting_help_faq_answer_4"> <div class="answer" id="certificates_and_credits_faq_answer_0">
<p>Online learners who receive a passing grade for a course will receive a certificate of mastery from edX and the underlying X University that offered the course. For example, a certificate of mastery for MITx’s 6.002x Circuits & Electronics will come from edX and MITx.</p> <p>Online learners who receive a passing grade for a course will receive a certificate of mastery from edX and the underlying X University that offered the course. For example, a certificate of mastery for MITx’s 6.002x Circuits & Electronics will come from edX and MITx.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">How are edX certificates delivered?</h3> <h3 class="question">How are edX certificates delivered?</h3>
<div class ="answer" id="getting_help_faq_answer_5"> <div class ="answer" id="certificates_and_credits_faq_answer_1">
<p>EdX certificates are delivered online through edx.org. So be sure to check your email in the weeks following the final grading – you will be able to download and print your certificate.</p> <p>EdX certificates are delivered online through edx.org. So be sure to check your email in the weeks following the final grading – you will be able to download and print your certificate.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">What is the difference between a proctored certificate and an honor code certificate?</h3> <h3 class="question">What is the difference between a proctored certificate and an honor code certificate?</h3>
<div class ="answer" id="getting_help_faq_answer_6"> <div class ="answer" id="certificates_and_credits_faq_answer_2">
<p>A proctored certificate is given to students who take and pass an exam under proctored conditions. An honor-code certificate is given to students who have completed all of the necessary online coursework associated with a course and have signed <link> the edX honor code </link>.</p> <p>A proctored certificate is given to students who take and pass an exam under proctored conditions. An honor-code certificate is given to students who have completed all of the necessary online coursework associated with a course and have signed <link> the edX honor code </link>.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">Yes. The requirements for both certificates can be independently satisfied.</h3> <h3 class="question">Can I get both a proctored certificate and an honor code certificate?</h3>
<div class ="answer" id="getting_help_faq_answer_7"> <div class="answer" id="certificates_and_credits_faq_answer_3">
<p>It is certainly possible to pass an edX course if you miss a week; however, coursework is progressive, so you should review and study what you may have missed. You can check your progress dashboard in the course to see your course average along the way if you have any concerns.</p> <p>Yes. The requirements for both certificates can be independently satisfied.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">Will my grade be shown on my certificate?</h3> <h3 class="question">Will my grade be shown on my certificate?</h3>
<div class ="answer" id="getting_help_faq_answer_8"> <div class ="answer" id="certificates_and_credits_faq_answer_4">
<p>No. Grades are not displayed on either honor code or proctored certificates.</p> <p>No. Grades are not displayed on either honor code or proctored certificates.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">How can I talk to professors, fellows and teaching assistants?</h3> <h3 class="question">How can I talk to professors, fellows and teaching assistants?</h3>
<div class ="answer" id="getting_help_faq_answer_9"> <div class ="answer" id="certificates_and_credits_faq_answer_5">
<p>The Discussion Forums are the best place to reach out to the edX teaching team for your class, and you don’t have to wait in line or rearrange your schedule to fit your professor’s – just post your questions. The response isn’t always immediate, but it’s usually pretty darned quick.</p> <p>The Discussion Forums are the best place to reach out to the edX teaching team for your class, and you don’t have to wait in line or rearrange your schedule to fit your professor’s – just post your questions. The response isn’t always immediate, but it’s usually pretty darned quick.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">The only certificates distributed with grades by edX were for the initial prototype course.</h3> <h3 class="question">The only certificates distributed with grades by edX were for the initial prototype course.</h3>
<div class ="answer" id="getting_help_faq_answer_10"> <div class ="answer" id="certificates_and_credits_faq_answer_6">
<p>You may unregister from an edX course at anytime, there are absolutely no penalties associated with incomplete edX studies, and you may register for the same course (provided we are still offering it) at a later time.</p> <p>You may unregister from an edX course at anytime, there are absolutely no penalties associated with incomplete edX studies, and you may register for the same course (provided we are still offering it) at a later time.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">Will my university accept my edX coursework for credit?</h3> <h3 class="question">Will my university accept my edX coursework for credit?</h3>
<div class ="answer" id="getting_help_faq_answer_11"> <div class ="answer" id="certificates_and_credits_faq_answer_7">
<p>Each educational institution makes its own decision regarding whether to accept edX coursework for credit. Check with your university for its policy.</p> <p>Each educational institution makes its own decision regarding whether to accept edX coursework for credit. Check with your university for its policy.</p>
</div> </div>
</article> </article>
<article class="response"> <article class="response">
<h3 class="question">I lost my edX certificate – can you resend it to me?</h3> <h3 class="question">I lost my edX certificate – can you resend it to me?</h3>
<div class ="answer" id="getting_help_faq_answer_12"> <div class ="answer" id="certificates_and_credits_faq_answer_8">
<p>Please log back in to your account to find certificates from the same profile page where they were originally posted. You will be able to re-print your certificate from there.</p> <p>Please log back in to your account to find certificates from the same profile page where they were originally posted. You will be able to re-print your certificate from there.</p>
</div> </div>
</article> </article>
...@@ -292,6 +295,13 @@ ...@@ -292,6 +295,13 @@
<p>Please check your browser and settings. We recommend downloading the current version of Firefox or Chrome. Alternatively, you may re-register with a different email account. There is no need to delete the old account, as it will disappear if unused.</p> <p>Please check your browser and settings. We recommend downloading the current version of Firefox or Chrome. Alternatively, you may re-register with a different email account. There is no need to delete the old account, as it will disappear if unused.</p>
</div> </div>
</article> </article>
<article class="response">
<h3 class="question">How can I help edX?</h3>
<div class ="answer" id="edx_basics_faq_answer_6">
<p>You may not realize it, but just by taking a course you are helping edX. That’s because the edX platform has been specifically designed to not only teach, but also gather data about learning. EdX will utilize this data to find out how to improve education online and on-campus.</p>
</div>
</article>
</section> </section>
</section> </section>
...@@ -299,7 +309,7 @@ ...@@ -299,7 +309,7 @@
<nav class="categories"> <nav class="categories">
<a href="#edx_basics_faq">edX Basics</a> <a href="#edx_basics_faq">edX Basics</a>
<a href="#classes_faq">The Classes</a> <a href="#classes_faq">The Classes</a>
<a href="#getting_help_faq">Getting Help</a> <a href="#certificates_and_credits_faq">Certificates and Credits</a>
<a href="#open_source_faq">edX & Open source</a> <a href="#open_source_faq">edX & Open source</a>
<a href="#other_help_faq">Other Help Questions - Account Questions</a> <a href="#other_help_faq">Other Help Questions - Account Questions</a>
</nav> </nav>
......
...@@ -51,6 +51,40 @@ ...@@ -51,6 +51,40 @@
</div> </div>
</article> </article>
<article id="associate-legal-counsel" class="job">
<div class="inner-wrapper">
<h3><strong>ASSOCIATE LEGAL COUNSEL</strong></h3>
<p>We are seeking a talented lawyer with the ability to operate independently in a fast-paced environment and work proactively with all members of the edX team. You must have thorough knowledge of intellectual property law, contracts and licensing. </p>
<p><strong>Key Responsibilities: </strong></p>
<ul>
<li>Drive the negotiating, reviewing, drafting and overseeing of a wide range of transactional arrangements, including collaborations related to the provision of online education, inbound and outbound licensing of intellectual property, strategic partnerships, nondisclosure agreements, and services agreements.</li>
<li>Provide counseling on the legal implications/considerations of business and technical strategies and projects, with special emphasis on regulations related to higher education, data security and privacy.</li>
<li>Provide advice and support company-wide on a variety of legal issues in a timely and effective manner.</li>
<li>Assist on other matters as needed.</li>
</ul>
<p><strong>Requirements:</strong></p>
<li>JD from an accredited law school</li>
<li>Massachusetts bar admission required</li>
<li>2-3 years of transactional experience at a major law firm and/or as an in-house counselor</li>
<li>Substantial IP licensing experience</li>
<li>Knowledge of copyright, trademark and patent law</li>
<li>Experience with open source content and open source software preferred</li>
<li>Outstanding communications skills (written and oral)</li>
<li>Experience with drafting and legal review of internet privacy policies and terms of use.</li>
<li>Understanding of how to balance legal risks with business objectives</li>
<li>Ability to develop an innovative approach to legal issues in support of strategic business initiatives</li>
<li>An internal business and customer focused proactive attitude with ability to prioritize effectively</li>
<li>Experience with higher education preferred but not required</li>
</ul>
<p>If you are interested in this position, please send an email to <a href="mailto:jobs@edx.org">jobs@edx.org</a>.</p>
</div>
</article>
<article id="instructional-designer" class="job"> <article id="instructional-designer" class="job">
<div class="inner-wrapper"> <div class="inner-wrapper">
<h3><strong>INSTRUCTIONAL DESIGNER</strong> &mdash; CONTRACT OPPORTUNITY</h3> <h3><strong>INSTRUCTIONAL DESIGNER</strong> &mdash; CONTRACT OPPORTUNITY</h3>
......
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