Merge branch 'master' into diana/open-ended-ui-updates

This commit is contained in:
Diana Huang
2013-01-17 10:09:16 -05:00
36 changed files with 3324 additions and 578 deletions

View File

@@ -1,14 +1,17 @@
import csv
import uuid
from collections import defaultdict, OrderedDict
from collections import OrderedDict
from datetime import datetime
from os.path import isdir
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand
from student.models import TestCenterUser
class Command(BaseCommand):
CSV_TO_MODEL_FIELDS = OrderedDict([
# Skipping optional field CandidateID
("ClientCandidateID", "client_candidate_id"),
("FirstName", "first_name"),
("LastName", "last_name"),
@@ -34,9 +37,17 @@ class Command(BaseCommand):
("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
])
args = '<output_file>'
option_list = BaseCommand.option_list + (
make_option(
'--dump_all',
action='store_true',
dest='dump_all',
),
)
args = '<output_file_or_dir>'
help = """
Export user information from TestCenterUser model into a tab delimited
Export user demographic information from TestCenterUser model into a tab delimited
text file with a format that Pearson expects.
"""
def handle(self, *args, **kwargs):
@@ -44,9 +55,33 @@ class Command(BaseCommand):
print Command.help
return
self.reset_sample_data()
# update time should use UTC in order to be comparable to the user_updated_at
# field
uploaded_at = datetime.utcnow()
with open(args[0], "wb") as outfile:
# if specified destination is an existing directory, then
# create a filename for it automatically. If it doesn't exist,
# or exists as a file, then we will just write to it.
# Name will use timestamp -- this is UTC, so it will look funny,
# but it should at least be consistent with the other timestamps
# used in the system.
dest = args[0]
if isdir(dest):
destfile = os.path.join(dest, uploaded_at.strftime("cdd-%Y%m%d-%H%M%S.dat"))
else:
destfile = dest
# strings must be in latin-1 format. CSV parser will
# otherwise convert unicode objects to ascii.
def ensure_encoding(value):
if isinstance(value, unicode):
return value.encode('iso-8859-1')
else:
return value
dump_all = kwargs['dump_all']
with open(destfile, "wb") as outfile:
writer = csv.DictWriter(outfile,
Command.CSV_TO_MODEL_FIELDS,
delimiter="\t",
@@ -54,103 +89,14 @@ class Command(BaseCommand):
extrasaction='ignore')
writer.writeheader()
for tcu in TestCenterUser.objects.order_by('id'):
record = dict((csv_field, getattr(tcu, model_field))
for csv_field, model_field
in Command.CSV_TO_MODEL_FIELDS.items())
record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
writer.writerow(record)
if dump_all or tcu.needs_uploading:
record = dict((csv_field, ensure_encoding(getattr(tcu, model_field)))
for csv_field, model_field
in Command.CSV_TO_MODEL_FIELDS.items())
record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
writer.writerow(record)
tcu.uploaded_at = uploaded_at
tcu.save()
def reset_sample_data(self):
def make_sample(**kwargs):
data = dict((model_field, kwargs.get(model_field, ""))
for model_field in Command.CSV_TO_MODEL_FIELDS.values())
return TestCenterUser(**data)
def generate_id():
return "edX{:012}".format(uuid.uuid4().int % (10**12))
# TestCenterUser.objects.all().delete()
samples = [
make_sample(
client_candidate_id=generate_id(),
first_name="Jack",
last_name="Doe",
middle_name="C",
address_1="11 Cambridge Center",
address_2="Suite 101",
city="Cambridge",
state="MA",
postal_code="02140",
country="USA",
phone="(617)555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Clyde",
last_name="Smith",
middle_name="J",
suffix="Jr.",
salutation="Mr.",
address_1="1 Penny Lane",
city="Honolulu",
state="HI",
postal_code="96792",
country="USA",
phone="555-555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Patty",
last_name="Lee",
salutation="Dr.",
address_1="P.O. Box 555",
city="Honolulu",
state="HI",
postal_code="96792",
country="USA",
phone="808-555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Jimmy",
last_name="James",
address_1="2020 Palmer Blvd.",
city="Springfield",
state="MA",
postal_code="96792",
country="USA",
phone="917-555-5555",
phone_country_code="1",
extension="2039",
fax="917-555-5556",
fax_country_code="1",
company_name="ACME Traps",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Yeong-Un",
last_name="Seo",
address_1="Duryu, Lotte 101",
address_2="Apt 55",
city="Daegu",
country="KOR",
phone="917-555-5555",
phone_country_code="011",
user_updated_at=datetime.utcnow()
),
]
for tcu in samples:
tcu.save()

View File

@@ -1,150 +1,93 @@
import csv
import uuid
from collections import defaultdict, OrderedDict
from collections import OrderedDict
from datetime import datetime
from os.path import isdir, join
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand
from student.models import TestCenterUser
def generate_id():
return "{:012}".format(uuid.uuid4().int % (10**12))
from student.models import TestCenterRegistration
class Command(BaseCommand):
args = '<output_file>'
CSV_TO_MODEL_FIELDS = OrderedDict([
('AuthorizationTransactionType', 'authorization_transaction_type'),
('AuthorizationID', 'authorization_id'),
('ClientAuthorizationID', 'client_authorization_id'),
('ClientCandidateID', 'client_candidate_id'),
('ExamAuthorizationCount', 'exam_authorization_count'),
('ExamSeriesCode', 'exam_series_code'),
('Accommodations', 'accommodation_code'),
('EligibilityApptDateFirst', 'eligibility_appointment_date_first'),
('EligibilityApptDateLast', 'eligibility_appointment_date_last'),
("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
])
args = '<output_file_or_dir>'
help = """
Export user information from TestCenterUser model into a tab delimited
Export user registration information from TestCenterRegistration model into a tab delimited
text file with a format that Pearson expects.
"""
FIELDS = [
'AuthorizationTransactionType',
'AuthorizationID',
'ClientAuthorizationID',
'ClientCandidateID',
'ExamAuthorizationCount',
'ExamSeriesCode',
'EligibilityApptDateFirst',
'EligibilityApptDateLast',
'LastUpdate',
]
option_list = BaseCommand.option_list + (
make_option(
'--dump_all',
action='store_true',
dest='dump_all',
),
make_option(
'--force_add',
action='store_true',
dest='force_add',
),
)
def handle(self, *args, **kwargs):
if len(args) < 1:
print Command.help
return
# self.reset_sample_data()
# update time should use UTC in order to be comparable to the user_updated_at
# field
uploaded_at = datetime.utcnow()
with open(args[0], "wb") as outfile:
# if specified destination is an existing directory, then
# create a filename for it automatically. If it doesn't exist,
# or exists as a file, then we will just write to it.
# Name will use timestamp -- this is UTC, so it will look funny,
# but it should at least be consistent with the other timestamps
# used in the system.
dest = args[0]
if isdir(dest):
destfile = join(dest, uploaded_at.strftime("ead-%Y%m%d-%H%M%S.dat"))
else:
destfile = dest
dump_all = kwargs['dump_all']
with open(destfile, "wb") as outfile:
writer = csv.DictWriter(outfile,
Command.FIELDS,
Command.CSV_TO_MODEL_FIELDS,
delimiter="\t",
quoting=csv.QUOTE_MINIMAL,
extrasaction='ignore')
writer.writeheader()
for tcu in TestCenterUser.objects.order_by('id')[:5]:
record = defaultdict(
lambda: "",
AuthorizationTransactionType="Add",
ClientAuthorizationID=generate_id(),
ClientCandidateID=tcu.client_candidate_id,
ExamAuthorizationCount="1",
ExamSeriesCode="6002x001",
EligibilityApptDateFirst="2012/12/15",
EligibilityApptDateLast="2012/12/30",
LastUpdate=datetime.utcnow().strftime("%Y/%m/%d %H:%M:%S")
)
writer.writerow(record)
for tcr in TestCenterRegistration.objects.order_by('id'):
if dump_all or tcr.needs_uploading:
record = dict((csv_field, getattr(tcr, model_field))
for csv_field, model_field
in Command.CSV_TO_MODEL_FIELDS.items())
record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
record["EligibilityApptDateFirst"] = record["EligibilityApptDateFirst"].strftime("%Y/%m/%d")
record["EligibilityApptDateLast"] = record["EligibilityApptDateLast"].strftime("%Y/%m/%d")
if kwargs['force_add']:
record['AuthorizationTransactionType'] = 'Add'
writer.writerow(record)
tcr.uploaded_at = uploaded_at
tcr.save()
def reset_sample_data(self):
def make_sample(**kwargs):
data = dict((model_field, kwargs.get(model_field, ""))
for model_field in Command.CSV_TO_MODEL_FIELDS.values())
return TestCenterUser(**data)
# TestCenterUser.objects.all().delete()
samples = [
make_sample(
client_candidate_id=generate_id(),
first_name="Jack",
last_name="Doe",
middle_name="C",
address_1="11 Cambridge Center",
address_2="Suite 101",
city="Cambridge",
state="MA",
postal_code="02140",
country="USA",
phone="(617)555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Clyde",
last_name="Smith",
middle_name="J",
suffix="Jr.",
salutation="Mr.",
address_1="1 Penny Lane",
city="Honolulu",
state="HI",
postal_code="96792",
country="USA",
phone="555-555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Patty",
last_name="Lee",
salutation="Dr.",
address_1="P.O. Box 555",
city="Honolulu",
state="HI",
postal_code="96792",
country="USA",
phone="808-555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Jimmy",
last_name="James",
address_1="2020 Palmer Blvd.",
city="Springfield",
state="MA",
postal_code="96792",
country="USA",
phone="917-555-5555",
phone_country_code="1",
extension="2039",
fax="917-555-5556",
fax_country_code="1",
company_name="ACME Traps",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Yeong-Un",
last_name="Seo",
address_1="Duryu, Lotte 101",
address_2="Apt 55",
city="Daegu",
country="KOR",
phone="917-555-5555",
phone_country_code="011",
user_updated_at=datetime.utcnow()
),
]
for tcu in samples:
tcu.save()

View File

@@ -0,0 +1,196 @@
from optparse import make_option
from time import strftime
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError
from student.models import TestCenterUser, TestCenterRegistration, TestCenterRegistrationForm, get_testcenter_registration
from student.views import course_from_id
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError
class Command(BaseCommand):
option_list = BaseCommand.option_list + (
# registration info:
make_option(
'--accommodation_request',
action='store',
dest='accommodation_request',
),
make_option(
'--accommodation_code',
action='store',
dest='accommodation_code',
),
make_option(
'--client_authorization_id',
action='store',
dest='client_authorization_id',
),
# exam info:
make_option(
'--exam_series_code',
action='store',
dest='exam_series_code',
),
make_option(
'--eligibility_appointment_date_first',
action='store',
dest='eligibility_appointment_date_first',
help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.'
),
make_option(
'--eligibility_appointment_date_last',
action='store',
dest='eligibility_appointment_date_last',
help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.'
),
# internal values:
make_option(
'--authorization_id',
action='store',
dest='authorization_id',
help='ID we receive from Pearson for a particular authorization'
),
make_option(
'--upload_status',
action='store',
dest='upload_status',
help='status value assigned by Pearson'
),
make_option(
'--upload_error_message',
action='store',
dest='upload_error_message',
help='error message provided by Pearson on a failure.'
),
# control values:
make_option(
'--ignore_registration_dates',
action='store_true',
dest='ignore_registration_dates',
help='find exam info for course based on exam_series_code, even if the exam is not active.'
),
)
args = "<student_username course_id>"
help = "Create or modify a TestCenterRegistration entry for a given Student"
@staticmethod
def is_valid_option(option_name):
base_options = set(option.dest for option in BaseCommand.option_list)
return option_name not in base_options
def handle(self, *args, **options):
username = args[0]
course_id = args[1]
print username, course_id
our_options = dict((k, v) for k, v in options.items()
if Command.is_valid_option(k) and v is not None)
try:
student = User.objects.get(username=username)
except User.DoesNotExist:
raise CommandError("User \"{}\" does not exist".format(username))
try:
testcenter_user = TestCenterUser.objects.get(user=student)
except TestCenterUser.DoesNotExist:
raise CommandError("User \"{}\" does not have an existing demographics record".format(username))
# check to see if a course_id was specified, and use information from that:
try:
course = course_from_id(course_id)
if 'ignore_registration_dates' in our_options:
examlist = [exam for exam in course.test_center_exams if exam.exam_series_code == our_options.get('exam_series_code')]
exam = examlist[0] if len(examlist) > 0 else None
else:
exam = course.current_test_center_exam
except ItemNotFoundError:
# otherwise use explicit values (so we don't have to define a course):
exam_name = "Dummy Placeholder Name"
exam_info = { 'Exam_Series_Code': our_options['exam_series_code'],
'First_Eligible_Appointment_Date' : our_options['eligibility_appointment_date_first'],
'Last_Eligible_Appointment_Date' : our_options['eligibility_appointment_date_last'],
}
exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info)
# update option values for date_first and date_last to use YYYY-MM-DD format
# instead of YYYY-MM-DDTHH:MM
our_options['eligibility_appointment_date_first'] = strftime("%Y-%m-%d", exam.first_eligible_appointment_date)
our_options['eligibility_appointment_date_last'] = strftime("%Y-%m-%d", exam.last_eligible_appointment_date)
if exam is None:
raise CommandError("Exam for course_id {%s} does not exist".format(course_id))
exam_code = exam.exam_series_code
UPDATE_FIELDS = ( 'accommodation_request',
'accommodation_code',
'client_authorization_id',
'exam_series_code',
'eligibility_appointment_date_first',
'eligibility_appointment_date_last',
)
# create and save the registration:
needs_updating = False
registrations = get_testcenter_registration(student, course_id, exam_code)
if len(registrations) > 0:
registration = registrations[0]
for fieldname in UPDATE_FIELDS:
if fieldname in our_options and registration.__getattribute__(fieldname) != our_options[fieldname]:
needs_updating = True;
else:
accommodation_request = our_options.get('accommodation_request','')
registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request)
needs_updating = True
if needs_updating:
# first update the record with the new values, if any:
for fieldname in UPDATE_FIELDS:
if fieldname in our_options and fieldname not in TestCenterRegistrationForm.Meta.fields:
registration.__setattr__(fieldname, our_options[fieldname])
# the registration form normally populates the data dict with
# the accommodation request (if any). But here we want to
# specify only those values that might change, so update the dict with existing
# values.
form_options = dict(our_options)
for propname in TestCenterRegistrationForm.Meta.fields:
if propname not in form_options:
form_options[propname] = registration.__getattribute__(propname)
form = TestCenterRegistrationForm(instance=registration, data=form_options)
if form.is_valid():
form.update_and_save()
print "Updated registration information for user's registration: username \"{}\" course \"{}\", examcode \"{}\"".format(student.username, course_id, exam_code)
else:
if (len(form.errors) > 0):
print "Field Form errors encountered:"
for fielderror in form.errors:
print "Field Form Error: %s" % fielderror
if (len(form.non_field_errors()) > 0):
print "Non-field Form errors encountered:"
for nonfielderror in form.non_field_errors:
print "Non-field Form Error: %s" % nonfielderror
else:
print "No changes necessary to make to existing user's registration."
# override internal values:
change_internal = False
if 'exam_series_code' in our_options:
exam_code = our_options['exam_series_code']
registration = get_testcenter_registration(student, course_id, exam_code)[0]
for internal_field in [ 'upload_error_message', 'upload_status', 'authorization_id']:
if internal_field in our_options:
registration.__setattr__(internal_field, our_options[internal_field])
change_internal = True
if change_internal:
print "Updated confirmation information in existing user's registration."
registration.save()
else:
print "No changes necessary to make to confirmation information in existing user's registration."

View File

@@ -1,35 +1,53 @@
import uuid
from datetime import datetime
from optparse import make_option
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand
from student.models import TestCenterUser
from student.models import TestCenterUser, TestCenterUserForm
class Command(BaseCommand):
option_list = BaseCommand.option_list + (
make_option(
'--client_candidate_id',
action='store',
dest='client_candidate_id',
help='ID we assign a user to identify them to Pearson'
),
# demographics:
make_option(
'--first_name',
action='store',
dest='first_name',
),
make_option(
'--middle_name',
action='store',
dest='middle_name',
),
make_option(
'--last_name',
action='store',
dest='last_name',
),
make_option(
'--suffix',
action='store',
dest='suffix',
),
make_option(
'--salutation',
action='store',
dest='salutation',
),
make_option(
'--address_1',
action='store',
dest='address_1',
),
make_option(
'--address_2',
action='store',
dest='address_2',
),
make_option(
'--address_3',
action='store',
dest='address_3',
),
make_option(
'--city',
action='store',
@@ -58,15 +76,56 @@ class Command(BaseCommand):
dest='phone',
help='Pretty free-form (parens, spaces, dashes), but no country code'
),
make_option(
'--extension',
action='store',
dest='extension',
),
make_option(
'--phone_country_code',
action='store',
dest='phone_country_code',
help='Phone country code, just "1" for the USA'
),
make_option(
'--fax',
action='store',
dest='fax',
help='Pretty free-form (parens, spaces, dashes), but no country code'
),
make_option(
'--fax_country_code',
action='store',
dest='fax_country_code',
help='Fax country code, just "1" for the USA'
),
make_option(
'--company_name',
action='store',
dest='company_name',
),
# internal values:
make_option(
'--client_candidate_id',
action='store',
dest='client_candidate_id',
help='ID we assign a user to identify them to Pearson'
),
make_option(
'--upload_status',
action='store',
dest='upload_status',
help='status value assigned by Pearson'
),
make_option(
'--upload_error_message',
action='store',
dest='upload_error_message',
help='error message provided by Pearson on a failure.'
),
)
args = "<student_username>"
help = "Create a TestCenterUser entry for a given Student"
help = "Create or modify a TestCenterUser entry for a given Student"
@staticmethod
def is_valid_option(option_name):
@@ -79,7 +138,52 @@ class Command(BaseCommand):
print username
our_options = dict((k, v) for k, v in options.items()
if Command.is_valid_option(k))
if Command.is_valid_option(k) and v is not None)
student = User.objects.get(username=username)
student.test_center_user = TestCenterUser(**our_options)
student.test_center_user.save()
try:
testcenter_user = TestCenterUser.objects.get(user=student)
needs_updating = testcenter_user.needs_update(our_options)
except TestCenterUser.DoesNotExist:
# do additional initialization here:
testcenter_user = TestCenterUser.create(student)
needs_updating = True
if needs_updating:
# the registration form normally populates the data dict with
# all values from the testcenter_user. But here we only want to
# specify those values that change, so update the dict with existing
# values.
form_options = dict(our_options)
for propname in TestCenterUser.user_provided_fields():
if propname not in form_options:
form_options[propname] = testcenter_user.__getattribute__(propname)
form = TestCenterUserForm(instance=testcenter_user, data=form_options)
if form.is_valid():
form.update_and_save()
else:
if (len(form.errors) > 0):
print "Field Form errors encountered:"
for fielderror in form.errors:
print "Field Form Error: %s" % fielderror
if (len(form.non_field_errors()) > 0):
print "Non-field Form errors encountered:"
for nonfielderror in form.non_field_errors:
print "Non-field Form Error: %s" % nonfielderror
else:
print "No changes necessary to make to existing user's demographics."
# override internal values:
change_internal = False
testcenter_user = TestCenterUser.objects.get(user=student)
for internal_field in [ 'upload_error_message', 'upload_status', 'client_candidate_id']:
if internal_field in our_options:
testcenter_user.__setattr__(internal_field, our_options[internal_field])
change_internal = True
if change_internal:
testcenter_user.save()
print "Updated confirmation information in existing user's demographics."
else:
print "No changes necessary to make to confirmation information in existing user's demographics."

View File

@@ -26,14 +26,17 @@ class Migration(SchemaMigration):
def forwards(self, orm):
"Kill the askbot"
# For MySQL, we're batching the alters together for performance reasons
if db.backend_name == 'mysql':
drops = ["drop `{0}`".format(col) for col in ASKBOT_AUTH_USER_COLUMNS]
statement = "alter table `auth_user` {0};".format(", ".join(drops))
db.execute(statement)
else:
for column in ASKBOT_AUTH_USER_COLUMNS:
db.delete_column('auth_user', column)
try:
# For MySQL, we're batching the alters together for performance reasons
if db.backend_name == 'mysql':
drops = ["drop `{0}`".format(col) for col in ASKBOT_AUTH_USER_COLUMNS]
statement = "alter table `auth_user` {0};".format(", ".join(drops))
db.execute(statement)
else:
for column in ASKBOT_AUTH_USER_COLUMNS:
db.delete_column('auth_user', column)
except Exception as ex:
print "Couldn't remove askbot because of {0} -- it was probably never here to begin with.".format(ex)
def backwards(self, orm):
raise RuntimeError("Cannot reverse this migration: there's no going back to Askbot.")

View File

@@ -0,0 +1,241 @@
# -*- 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 'TestCenterRegistration'
db.create_table('student_testcenterregistration', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('testcenter_user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['student.TestCenterUser'])),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)),
('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)),
('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
('client_authorization_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=20, db_index=True)),
('exam_series_code', self.gf('django.db.models.fields.CharField')(max_length=15, db_index=True)),
('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)),
('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)),
('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)),
('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=1024, blank=True)),
('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
('upload_status', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)),
('upload_error_message', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)),
('authorization_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)),
('confirmed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
))
db.send_create_signal('student', ['TestCenterRegistration'])
# Adding field 'TestCenterUser.uploaded_at'
db.add_column('student_testcenteruser', 'uploaded_at',
self.gf('django.db.models.fields.DateTimeField')(db_index=True, null=True, blank=True),
keep_default=False)
# Adding field 'TestCenterUser.processed_at'
db.add_column('student_testcenteruser', 'processed_at',
self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True),
keep_default=False)
# Adding field 'TestCenterUser.upload_status'
db.add_column('student_testcenteruser', 'upload_status',
self.gf('django.db.models.fields.CharField')(db_index=True, default='', max_length=20, blank=True),
keep_default=False)
# Adding field 'TestCenterUser.upload_error_message'
db.add_column('student_testcenteruser', 'upload_error_message',
self.gf('django.db.models.fields.CharField')(default='', max_length=512, blank=True),
keep_default=False)
# Adding field 'TestCenterUser.confirmed_at'
db.add_column('student_testcenteruser', 'confirmed_at',
self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True),
keep_default=False)
# Adding index on 'TestCenterUser', fields ['company_name']
db.create_index('student_testcenteruser', ['company_name'])
# Adding unique constraint on 'TestCenterUser', fields ['client_candidate_id']
db.create_unique('student_testcenteruser', ['client_candidate_id'])
def backwards(self, orm):
# Removing unique constraint on 'TestCenterUser', fields ['client_candidate_id']
db.delete_unique('student_testcenteruser', ['client_candidate_id'])
# Removing index on 'TestCenterUser', fields ['company_name']
db.delete_index('student_testcenteruser', ['company_name'])
# Deleting model 'TestCenterRegistration'
db.delete_table('student_testcenterregistration')
# Deleting field 'TestCenterUser.uploaded_at'
db.delete_column('student_testcenteruser', 'uploaded_at')
# Deleting field 'TestCenterUser.processed_at'
db.delete_column('student_testcenteruser', 'processed_at')
# Deleting field 'TestCenterUser.upload_status'
db.delete_column('student_testcenteruser', 'upload_status')
# Deleting field 'TestCenterUser.upload_error_message'
db.delete_column('student_testcenteruser', 'upload_error_message')
# Deleting field 'TestCenterUser.confirmed_at'
db.delete_column('student_testcenteruser', 'confirmed_at')
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'"},
'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']

View File

@@ -40,6 +40,8 @@ import hashlib
import json
import logging
import uuid
from random import randint
from time import strftime
from django.conf import settings
@@ -47,10 +49,10 @@ from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.forms import ModelForm, forms
import comment_client as cc
log = logging.getLogger(__name__)
@@ -125,6 +127,9 @@ class UserProfile(models.Model):
def set_meta(self, js):
self.meta = json.dumps(js)
TEST_CENTER_STATUS_ACCEPTED = "Accepted"
TEST_CENTER_STATUS_ERROR = "Error"
class TestCenterUser(models.Model):
"""This is our representation of the User for in-person testing, and
specifically for Pearson at this point. A few things to note:
@@ -140,6 +145,9 @@ class TestCenterUser(models.Model):
The field names and lengths are modeled on the conventions and constraints
of Pearson's data import system, including oddities such as suffix having
a limit of 255 while last_name only gets 50.
Also storing here the confirmation information received from Pearson (if any)
as to the success or failure of the upload. (VCDC file)
"""
# Our own record keeping...
user = models.ForeignKey(User, unique=True, default=None)
@@ -150,12 +158,8 @@ class TestCenterUser(models.Model):
# updated_at, this will not get incremented when we do a batch data import.
user_updated_at = models.DateTimeField(db_index=True)
# Unique ID given to us for this User by the Testing Center. It's null when
# we first create the User entry, and is assigned by Pearson later.
candidate_id = models.IntegerField(null=True, db_index=True)
# Unique ID we assign our user for a the Test Center.
client_candidate_id = models.CharField(max_length=50, db_index=True)
# Unique ID we assign our user for the Test Center.
client_candidate_id = models.CharField(unique=True, max_length=50, db_index=True)
# Name
first_name = models.CharField(max_length=30, db_index=True)
@@ -186,18 +190,369 @@ class TestCenterUser(models.Model):
fax_country_code = models.CharField(max_length=3, blank=True)
# Company
company_name = models.CharField(max_length=50, blank=True)
company_name = models.CharField(max_length=50, blank=True, db_index=True)
# time at which edX sent the registration to the test center
uploaded_at = models.DateTimeField(null=True, blank=True, db_index=True)
# confirmation back from the test center, as well as timestamps
# on when they processed the request, and when we received
# confirmation back.
processed_at = models.DateTimeField(null=True, db_index=True)
upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted'
upload_error_message = models.CharField(max_length=512, blank=True)
# Unique ID given to us for this User by the Testing Center. It's null when
# we first create the User entry, and may be assigned by Pearson later.
# (However, it may never be set if we are always initiating such candidate creation.)
candidate_id = models.IntegerField(null=True, db_index=True)
confirmed_at = models.DateTimeField(null=True, db_index=True)
@property
def needs_uploading(self):
return self.uploaded_at is None or self.uploaded_at < self.user_updated_at
@staticmethod
def user_provided_fields():
return [ 'first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country',
'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name']
@property
def email(self):
return self.user.email
def needs_update(self, fields):
for fieldname in TestCenterUser.user_provided_fields():
if fieldname in fields and getattr(self, fieldname) != fields[fieldname]:
return True
return False
@staticmethod
def _generate_edx_id(prefix):
NUM_DIGITS = 12
return u"{}{:012}".format(prefix, randint(1, 10**NUM_DIGITS-1))
@staticmethod
def _generate_candidate_id():
return TestCenterUser._generate_edx_id("edX")
@classmethod
def create(cls, user):
testcenter_user = cls(user=user)
# testcenter_user.candidate_id remains unset
# assign an ID of our own:
cand_id = cls._generate_candidate_id()
while TestCenterUser.objects.filter(client_candidate_id=cand_id).exists():
cand_id = cls._generate_candidate_id()
testcenter_user.client_candidate_id = cand_id
return testcenter_user
@property
def is_accepted(self):
return self.upload_status == TEST_CENTER_STATUS_ACCEPTED
@property
def is_rejected(self):
return self.upload_status == TEST_CENTER_STATUS_ERROR
@property
def is_pending(self):
return not self.is_accepted and not self.is_rejected
class TestCenterUserForm(ModelForm):
class Meta:
model = TestCenterUser
fields = ( 'first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country',
'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name')
def update_and_save(self):
new_user = self.save(commit=False)
# create additional values here:
new_user.user_updated_at = datetime.utcnow()
new_user.save()
log.info("Updated demographic information for user's test center exam registration: username \"{}\" ".format(new_user.username))
# add validation:
def clean_country(self):
code = self.cleaned_data['country']
if code and len(code) != 3:
raise forms.ValidationError(u'Must be three characters (ISO 3166-1): e.g. USA, CAN, MNG')
return code
def clean(self):
def _can_encode_as_latin(fieldvalue):
try:
fieldvalue.encode('iso-8859-1')
except UnicodeEncodeError:
return False
return True
cleaned_data = super(TestCenterUserForm, self).clean()
# check for interactions between fields:
if 'country' in cleaned_data:
country = cleaned_data.get('country')
if country == 'USA' or country == 'CAN':
if 'state' in cleaned_data and len(cleaned_data['state']) == 0:
self._errors['state'] = self.error_class([u'Required if country is USA or CAN.'])
del cleaned_data['state']
if 'postal_code' in cleaned_data and len(cleaned_data['postal_code']) == 0:
self._errors['postal_code'] = self.error_class([u'Required if country is USA or CAN.'])
del cleaned_data['postal_code']
if 'fax' in cleaned_data and len(cleaned_data['fax']) > 0 and 'fax_country_code' in cleaned_data and len(cleaned_data['fax_country_code']) == 0:
self._errors['fax_country_code'] = self.error_class([u'Required if fax is specified.'])
del cleaned_data['fax_country_code']
# check encoding for all fields:
cleaned_data_fields = [fieldname for fieldname in cleaned_data]
for fieldname in cleaned_data_fields:
if not _can_encode_as_latin(cleaned_data[fieldname]):
self._errors[fieldname] = self.error_class([u'Must only use characters in Latin-1 (iso-8859-1) encoding'])
del cleaned_data[fieldname]
# Always return the full collection of cleaned data.
return cleaned_data
# our own code to indicate that a request has been rejected.
ACCOMMODATION_REJECTED_CODE = 'NONE'
ACCOMMODATION_CODES = (
(ACCOMMODATION_REJECTED_CODE, 'No Accommodation Granted'),
('EQPMNT', 'Equipment'),
('ET12ET', 'Extra Time - 1/2 Exam Time'),
('ET30MN', 'Extra Time - 30 Minutes'),
('ETDBTM', 'Extra Time - Double Time'),
('SEPRMM', 'Separate Room'),
('SRREAD', 'Separate Room and Reader'),
('SRRERC', 'Separate Room and Reader/Recorder'),
('SRRECR', 'Separate Room and Recorder'),
('SRSEAN', 'Separate Room and Service Animal'),
('SRSGNR', 'Separate Room and Sign Language Interpreter'),
)
ACCOMMODATION_CODE_DICT = { code : name for (code, name) in ACCOMMODATION_CODES }
class TestCenterRegistration(models.Model):
"""
This is our representation of a user's registration for in-person testing,
and specifically for Pearson at this point. A few things to note:
* Pearson only supports Latin-1, so we have to make sure that the data we
capture here will work with that encoding. This is less of an issue
than for the TestCenterUser.
* Registrations are only created here when a user registers to take an exam in person.
The field names and lengths are modeled on the conventions and constraints
of Pearson's data import system.
"""
# to find an exam registration, we key off of the user and course_id.
# If multiple exams per course are possible, we would also need to add the
# exam_series_code.
testcenter_user = models.ForeignKey(TestCenterUser, default=None)
course_id = models.CharField(max_length=128, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True, db_index=True)
# user_updated_at happens only when the user makes a change to their data,
# and is something Pearson needs to know to manage updates. Unlike
# updated_at, this will not get incremented when we do a batch data import.
# The appointment dates, the exam count, and the accommodation codes can be updated,
# but hopefully this won't happen often.
user_updated_at = models.DateTimeField(db_index=True)
# "client_authorization_id" is our unique identifier for the authorization.
# This must be present for an update or delete to be sent to Pearson.
client_authorization_id = models.CharField(max_length=20, unique=True, db_index=True)
# information about the test, from the course policy:
exam_series_code = models.CharField(max_length=15, db_index=True)
eligibility_appointment_date_first = models.DateField(db_index=True)
eligibility_appointment_date_last = models.DateField(db_index=True)
# this is really a list of codes, using an '*' as a delimiter.
# So it's not a choice list. We use the special value of ACCOMMODATION_REJECTED_CODE
# to indicate the rejection of an accommodation request.
accommodation_code = models.CharField(max_length=64, blank=True)
# store the original text of the accommodation request.
accommodation_request = models.CharField(max_length=1024, blank=True, db_index=True)
# time at which edX sent the registration to the test center
uploaded_at = models.DateTimeField(null=True, db_index=True)
# confirmation back from the test center, as well as timestamps
# on when they processed the request, and when we received
# confirmation back.
processed_at = models.DateTimeField(null=True, db_index=True)
upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted'
upload_error_message = models.CharField(max_length=512, blank=True)
# Unique ID given to us for this registration by the Testing Center. It's null when
# we first create the registration entry, and may be assigned by Pearson later.
# (However, it may never be set if we are always initiating such candidate creation.)
authorization_id = models.IntegerField(null=True, db_index=True)
confirmed_at = models.DateTimeField(null=True, db_index=True)
@property
def candidate_id(self):
return self.testcenter_user.candidate_id
@property
def client_candidate_id(self):
return self.testcenter_user.client_candidate_id
@property
def authorization_transaction_type(self):
if self.authorization_id is not None:
return 'Update'
elif self.uploaded_at is None:
return 'Add'
else:
# TODO: decide what to send when we have uploaded an initial version,
# but have not received confirmation back from that upload. If the
# registration here has been changed, then we don't know if this changed
# registration should be submitted as an 'add' or an 'update'.
#
# If the first registration were lost or in error (e.g. bad code),
# the second should be an "Add". If the first were processed successfully,
# then the second should be an "Update". We just don't know....
return 'Update'
@property
def exam_authorization_count(self):
# TODO: figure out if this should really go in the database (with a default value).
return 1
@classmethod
def create(cls, testcenter_user, exam, accommodation_request):
registration = cls(testcenter_user = testcenter_user)
registration.course_id = exam.course_id
registration.accommodation_request = accommodation_request.strip()
registration.exam_series_code = exam.exam_series_code
registration.eligibility_appointment_date_first = strftime("%Y-%m-%d", exam.first_eligible_appointment_date)
registration.eligibility_appointment_date_last = strftime("%Y-%m-%d", exam.last_eligible_appointment_date)
registration.client_authorization_id = cls._create_client_authorization_id()
# accommodation_code remains blank for now, along with Pearson confirmation information
return registration
@staticmethod
def _generate_authorization_id():
return TestCenterUser._generate_edx_id("edXexam")
@staticmethod
def _create_client_authorization_id():
"""
Return a unique id for a registration, suitable for using as an authorization code
for Pearson. It must fit within 20 characters.
"""
# generate a random value, and check to see if it already is in use here
auth_id = TestCenterRegistration._generate_authorization_id()
while TestCenterRegistration.objects.filter(client_authorization_id=auth_id).exists():
auth_id = TestCenterRegistration._generate_authorization_id()
return auth_id
# methods for providing registration status details on registration page:
@property
def demographics_is_accepted(self):
return self.testcenter_user.is_accepted
@property
def demographics_is_rejected(self):
return self.testcenter_user.is_rejected
@property
def demographics_is_pending(self):
return self.testcenter_user.is_pending
@property
def accommodation_is_accepted(self):
return len(self.accommodation_request) > 0 and len(self.accommodation_code) > 0 and self.accommodation_code != ACCOMMODATION_REJECTED_CODE
@property
def accommodation_is_rejected(self):
return len(self.accommodation_request) > 0 and self.accommodation_code == ACCOMMODATION_REJECTED_CODE
@property
def accommodation_is_pending(self):
return len(self.accommodation_request) > 0 and len(self.accommodation_code) == 0
@property
def accommodation_is_skipped(self):
return len(self.accommodation_request) == 0
@property
def registration_is_accepted(self):
return self.upload_status == TEST_CENTER_STATUS_ACCEPTED
@property
def registration_is_rejected(self):
return self.upload_status == TEST_CENTER_STATUS_ERROR
@property
def registration_is_pending(self):
return not self.registration_is_accepted and not self.registration_is_rejected
# methods for providing registration status summary on dashboard page:
@property
def is_accepted(self):
return self.registration_is_accepted and self.demographics_is_accepted
@property
def is_rejected(self):
return self.registration_is_rejected or self.demographics_is_rejected
@property
def is_pending(self):
return not self.is_accepted and not self.is_rejected
def get_accommodation_codes(self):
return self.accommodation_code.split('*')
def get_accommodation_names(self):
return [ ACCOMMODATION_CODE_DICT.get(code, "Unknown code " + code) for code in self.get_accommodation_codes() ]
@property
def registration_signup_url(self):
return settings.PEARSONVUE_SIGNINPAGE_URL
class TestCenterRegistrationForm(ModelForm):
class Meta:
model = TestCenterRegistration
fields = ( 'accommodation_request', 'accommodation_code' )
def clean_accommodation_request(self):
code = self.cleaned_data['accommodation_request']
if code and len(code) > 0:
return code.strip()
return code
def update_and_save(self):
registration = self.save(commit=False)
# create additional values here:
registration.user_updated_at = datetime.utcnow()
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))
# TODO: add validation code for values added to accommodation_code field.
def get_testcenter_registration(user, course_id, exam_series_code):
try:
tcu = TestCenterUser.objects.get(user=user)
except TestCenterUser.DoesNotExist:
return []
return TestCenterRegistration.objects.filter(testcenter_user=tcu, course_id=course_id, exam_series_code=exam_series_code)
def unique_id_for_user(user):
"""
Return a unique id for a user, suitable for inserting into
e.g. personalized survey links.
"""
# include the secret key as a salt, and to make the ids unique accross
# include the secret key as a salt, and to make the ids unique across
# different LMS installs.
h = hashlib.md5()
h.update(settings.SECRET_KEY)

View File

@@ -1,15 +1,16 @@
import datetime
import feedparser
import itertools
#import itertools
import json
import logging
import random
import string
import sys
import time
#import time
import urllib
import uuid
from django.conf import settings
from django.contrib.auth import logout, authenticate, login
from django.contrib.auth.forms import PasswordResetForm
@@ -26,18 +27,19 @@ from bs4 import BeautifulSoup
from django.core.cache import cache
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
from student.models import (Registration, UserProfile,
from student.models import (Registration, UserProfile, TestCenterUser, TestCenterUserForm,
TestCenterRegistration, TestCenterRegistrationForm,
PendingNameChange, PendingEmailChange,
CourseEnrollment, unique_id_for_user)
CourseEnrollment, unique_id_for_user,
get_testcenter_registration)
from certificates.models import CertificateStatuses, certificate_status_for_student
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from datetime import date
#from datetime import date
from collections import namedtuple
from courseware.courses import get_courses
@@ -209,7 +211,7 @@ def _cert_info(user, course, cert_status):
def dashboard(request):
user = request.user
enrollments = CourseEnrollment.objects.filter(user=user)
# Build our courses list for the user, but ignore any courses that no longer
# exist (because the course IDs have changed). Still, we don't delete those
# enrollments, because it could have been a data push snafu.
@@ -239,6 +241,8 @@ def dashboard(request):
cert_statuses = { course.id: cert_info(request.user, course) for course in courses}
exam_registrations = { course.id: exam_registration_info(request.user, course) for course in courses}
# Get the 3 most recent news
top_news = _get_news(top=3)
@@ -249,6 +253,7 @@ def dashboard(request):
'show_courseware_links_for' : show_courseware_links_for,
'cert_statuses': cert_statuses,
'news': top_news,
'exam_registrations': exam_registrations,
}
return render_to_response('dashboard.html', context)
@@ -300,7 +305,7 @@ def change_enrollment(request):
try:
course = course_from_id(course_id)
except ItemNotFoundError:
log.warning("User {0} tried to enroll in non-existant course {1}"
log.warning("User {0} tried to enroll in non-existent course {1}"
.format(user.username, enrollment.course_id))
return {'success': False, 'error': 'The course requested does not exist.'}
@@ -466,8 +471,9 @@ def _do_create_account(post_vars):
try:
profile.year_of_birth = int(post_vars['year_of_birth'])
except (ValueError, KeyError):
profile.year_of_birth = None # If they give us garbage, just ignore it instead
# of asking them to put an integer.
# If they give us garbage, just ignore it instead
# of asking them to put an integer.
profile.year_of_birth = None
try:
profile.save()
except Exception:
@@ -599,6 +605,162 @@ def create_account(request, post_override=None):
js = {'success': True}
return HttpResponse(json.dumps(js), mimetype="application/json")
def exam_registration_info(user, course):
""" Returns a Registration object if the user is currently registered for a current
exam of the course. Returns None if the user is not registered, or if there is no
current exam for the course.
"""
exam_info = course.current_test_center_exam
if exam_info is None:
return None
exam_code = exam_info.exam_series_code
registrations = get_testcenter_registration(user, course.id, exam_code)
if registrations:
registration = registrations[0]
else:
registration = None
return registration
@login_required
@ensure_csrf_cookie
def begin_exam_registration(request, course_id):
""" Handles request to register the user for the current
test center exam of the specified course. Called by form
in dashboard.html.
"""
user = request.user
try:
course = (course_from_id(course_id))
except ItemNotFoundError:
# TODO: do more than just log!! The rest will fail, so we should fail right now.
log.error("User {0} enrolled in non-existent course {1}"
.format(user.username, course_id))
# get the exam to be registered for:
# (For now, we just assume there is one at most.)
exam_info = course.current_test_center_exam
# determine if the user is registered for this course:
registration = exam_registration_info(user, course)
# we want to populate the registration page with the relevant information,
# if it already exists. Create an empty object otherwise.
try:
testcenteruser = TestCenterUser.objects.get(user=user)
except TestCenterUser.DoesNotExist:
testcenteruser = TestCenterUser()
testcenteruser.user = user
context = {'course': course,
'user': user,
'testcenteruser': testcenteruser,
'registration': registration,
'exam_info': exam_info,
}
return render_to_response('test_center_register.html', context)
@ensure_csrf_cookie
def create_exam_registration(request, post_override=None):
'''
JSON call to create a test center exam registration.
Called by form in test_center_register.html
'''
post_vars = post_override if post_override else request.POST
# first determine if we need to create a new TestCenterUser, or if we are making any update
# to an existing TestCenterUser.
username = post_vars['username']
user = User.objects.get(username=username)
course_id = post_vars['course_id']
course = (course_from_id(course_id)) # assume it will be found....
try:
testcenter_user = TestCenterUser.objects.get(user=user)
needs_updating = testcenter_user.needs_update(post_vars)
log.info("User {0} enrolled in course {1} {2}updating demographic info for exam registration".format(user.username, course_id, "" if needs_updating else "not "))
except TestCenterUser.DoesNotExist:
# do additional initialization here:
testcenter_user = TestCenterUser.create(user)
needs_updating = True
log.info("User {0} enrolled in course {1} creating demographic info for exam registration".format(user.username, course_id))
# perform validation:
if needs_updating:
# first perform validation on the user information
# using a Django Form.
form = TestCenterUserForm(instance=testcenter_user, data=post_vars)
if form.is_valid():
form.update_and_save()
else:
response_data = {'success': False}
# return a list of errors...
response_data['field_errors'] = form.errors
response_data['non_field_errors'] = form.non_field_errors()
return HttpResponse(json.dumps(response_data), mimetype="application/json")
# create and save the registration:
needs_saving = False
exam = course.current_test_center_exam
exam_code = exam.exam_series_code
registrations = get_testcenter_registration(user, course_id, exam_code)
if registrations:
registration = registrations[0]
# NOTE: we do not bother to check here to see if the registration has changed,
# because at the moment there is no way for a user to change anything about their
# registration. They only provide an optional accommodation request once, and
# cannot make changes to it thereafter.
# It is possible that the exam_info content has been changed, such as the
# scheduled exam dates, but those kinds of changes should not be handled through
# this registration screen.
else:
accommodation_request = post_vars.get('accommodation_request','')
registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request)
needs_saving = True
log.info("User {0} enrolled in course {1} creating new exam registration".format(user.username, course_id))
if needs_saving:
# do validation of registration. (Mainly whether an accommodation request is too long.)
form = TestCenterRegistrationForm(instance=registration, data=post_vars)
if form.is_valid():
form.update_and_save()
else:
response_data = {'success': False}
# return a list of errors...
response_data['field_errors'] = form.errors
response_data['non_field_errors'] = form.non_field_errors()
return HttpResponse(json.dumps(response_data), mimetype="application/json")
# only do the following if there is accommodation text to send,
# and a destination to which to send it.
# TODO: still need to create the accommodation email templates
# if 'accommodation_request' in post_vars and 'TESTCENTER_ACCOMMODATION_REQUEST_EMAIL' in settings:
# d = {'accommodation_request': post_vars['accommodation_request'] }
#
# # composes accommodation email
# subject = render_to_string('emails/accommodation_email_subject.txt', d)
# # Email subject *must not* contain newlines
# subject = ''.join(subject.splitlines())
# message = render_to_string('emails/accommodation_email.txt', d)
#
# try:
# dest_addr = settings['TESTCENTER_ACCOMMODATION_REQUEST_EMAIL']
# from_addr = user.email
# send_mail(subject, message, from_addr, [dest_addr], fail_silently=False)
# except:
# log.exception(sys.exc_info())
# response_data = {'success': False}
# response_data['non_field_errors'] = [ 'Could not send accommodation e-mail.', ]
# return HttpResponse(json.dumps(response_data), mimetype="application/json")
js = {'success': True}
return HttpResponse(json.dumps(js), mimetype="application/json")
def get_random_post_override():
"""
@@ -654,7 +816,7 @@ def password_reset(request):
# By default, Django doesn't allow Users with is_active = False to reset their passwords,
# but this bites people who signed up a long time ago, never activated, and forgot their
# password. So for their sake, we'll auto-activate a user for whome password_reset is called.
# password. So for their sake, we'll auto-activate a user for whom password_reset is called.
try:
user = User.objects.get(email=request.POST['email'])
user.is_active = True

View File

@@ -97,6 +97,21 @@ class CourseDescriptor(SequenceDescriptor):
# disable the syllabus content for courses that do not provide a syllabus
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self.test_center_exams = []
test_center_info = self.metadata.get('testcenter_info')
if test_center_info is not None:
for exam_name in test_center_info:
try:
exam_info = test_center_info[exam_name]
self.test_center_exams.append(self.TestCenterExam(self.id, exam_name, exam_info))
except Exception as err:
# If we can't parse the test center exam info, don't break
# the rest of the courseware.
msg = 'Error %s: Unable to load test-center exam info for exam "%s" of course "%s"' % (err, exam_name, self.id)
log.error(msg)
continue
def set_grading_policy(self, policy_str):
"""Parse the policy specified in policy_str, and save it"""
try:
@@ -362,6 +377,88 @@ class CourseDescriptor(SequenceDescriptor):
"""
return self.metadata.get('end_of_course_survey_url')
class TestCenterExam(object):
def __init__(self, course_id, exam_name, exam_info):
self.course_id = course_id
self.exam_name = exam_name
self.exam_info = exam_info
self.exam_series_code = exam_info.get('Exam_Series_Code') or exam_name
self.display_name = exam_info.get('Exam_Display_Name') or self.exam_series_code
self.first_eligible_appointment_date = self._try_parse_time('First_Eligible_Appointment_Date')
if self.first_eligible_appointment_date is None:
raise ValueError("First appointment date must be specified")
# TODO: If defaulting the last appointment date, it should be the
# *end* of the same day, not the same time. It's going to be used as the
# end of the exam overall, so we don't want the exam to disappear too soon.
# It's also used optionally as the registration end date, so time matters there too.
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
if self.last_eligible_appointment_date is None:
raise ValueError("Last appointment date must be specified")
self.registration_start_date = self._try_parse_time('Registration_Start_Date') or time.gmtime(0)
self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date
# do validation within the exam info:
if self.registration_start_date > self.registration_end_date:
raise ValueError("Registration start date must be before registration end date")
if self.first_eligible_appointment_date > self.last_eligible_appointment_date:
raise ValueError("First appointment date must be before last appointment date")
if self.registration_end_date > self.last_eligible_appointment_date:
raise ValueError("Registration end date must be before last appointment date")
def _try_parse_time(self, key):
"""
Parse an optional metadata key containing a time: if present, complain
if it doesn't parse.
Return None if not present or invalid.
"""
if key in self.exam_info:
try:
return parse_time(self.exam_info[key])
except ValueError as e:
msg = "Exam {0} in course {1} loaded with a bad exam_info key '{2}': '{3}'".format(self.exam_name, self.course_id, self.exam_info[key], e)
log.warning(msg)
return None
def has_started(self):
return time.gmtime() > self.first_eligible_appointment_date
def has_ended(self):
return time.gmtime() > self.last_eligible_appointment_date
def has_started_registration(self):
return time.gmtime() > self.registration_start_date
def has_ended_registration(self):
return time.gmtime() > self.registration_end_date
def is_registering(self):
now = time.gmtime()
return now >= self.registration_start_date and now <= self.registration_end_date
@property
def first_eligible_appointment_date_text(self):
return time.strftime("%b %d, %Y", self.first_eligible_appointment_date)
@property
def last_eligible_appointment_date_text(self):
return time.strftime("%b %d, %Y", self.last_eligible_appointment_date)
@property
def registration_end_date_text(self):
return time.strftime("%b %d, %Y", self.registration_end_date)
@property
def current_test_center_exam(self):
exams = [exam for exam in self.test_center_exams if exam.has_started_registration() and not exam.has_ended()]
if len(exams) > 1:
# TODO: output some kind of warning. This should already be
# caught if we decide to do validation at load time.
return exams[0]
elif len(exams) == 1:
return exams[0]
else:
return None
@property
def title(self):
return self.display_name

View File

@@ -124,3 +124,7 @@ class RoundTripTestCase(unittest.TestCase):
def test_graphicslidertool_roundtrip(self):
#Test graphicslidertool xmodule to see if it exports correctly
self.check_export_roundtrip(DATA_DIR,"graphic_slider_tool")
def test_exam_registration_roundtrip(self):
# Test exam_registration xmodule to see if it exports correctly
self.check_export_roundtrip(DATA_DIR,"test_exam_registration")

View File

@@ -7,8 +7,11 @@ TIME_FORMAT = "%Y-%m-%dT%H:%M"
def parse_time(time_str):
"""
Takes a time string in TIME_FORMAT, returns
it as a time_struct. Raises ValueError if the string is not in the right format.
Takes a time string in TIME_FORMAT
Returns it as a time_struct.
Raises ValueError if the string is not in the right format.
"""
return time.strptime(time_str, TIME_FORMAT)

View File

@@ -414,7 +414,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
'xqa_key',
# TODO: This is used by the XMLModuleStore to provide for locations for
# static files, and will need to be removed when that code is removed
'data_dir'
'data_dir',
# How many days early to show a course element to beta testers (float)
# intended to be set per-course, but can be overridden in for specific
# elements. Can be a float.
'days_early_for_beta'
)
# cdodge: this is a list of metadata names which are 'system' metadata
@@ -497,12 +501,26 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
@property
def start(self):
"""
If self.metadata contains start, return it. Else return None.
If self.metadata contains a valid start time, return it as a time struct.
Else return None.
"""
if 'start' not in self.metadata:
return None
return self._try_parse_time('start')
@property
def days_early_for_beta(self):
"""
If self.metadata contains start, return the number, as a float. Else return None.
"""
if 'days_early_for_beta' not in self.metadata:
return None
try:
return float(self.metadata['days_early_for_beta'])
except ValueError:
return None
@property
def own_metadata(self):
"""
@@ -715,7 +733,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
"""
Parse an optional metadata key containing a time: if present, complain
if it doesn't parse.
Return None if not present or invalid.
Returns a time_struct, or None if metadata key is not present or is invalid.
"""
if key in self.metadata:
try:

View File

@@ -94,12 +94,18 @@ class XmlDescriptor(XModuleDescriptor):
'start', 'due', 'graded', 'display_name', 'url_name', 'hide_from_toc',
'ispublic', # if True, then course is listed for all users; see
'xqa_key', # for xqaa server access
# information about testcenter exams is a dict (of dicts), not a string,
# so it cannot be easily exportable as a course element's attribute.
'testcenter_info',
# VS[compat] Remove once unused.
'name', 'slug')
metadata_to_strip = ('data_dir',
# VS[compat] -- remove the below attrs once everything is in the CMS
'course', 'org', 'url_name', 'filename')
# information about testcenter exams is a dict (of dicts), not a string,
# so it cannot be easily exportable as a course element's attribute.
'testcenter_info',
# VS[compat] -- remove the below attrs once everything is in the CMS
'course', 'org', 'url_name', 'filename')
# A dictionary mapping xml attribute names AttrMaps that describe how
# to import and export them

View File

@@ -0,0 +1,2 @@
Simple course with test center exam information included in policy.json.

View File

@@ -0,0 +1 @@
roots/2012_Fall.xml

View File

@@ -0,0 +1,15 @@
<course>
<chapter url_name="Overview">
<videosequence url_name="Toy_Videos">
<html url_name="toylab"/>
<video url_name="Video_Resources" youtube="1.0:1bK-WdDi6Qw"/>
</videosequence>
<video url_name="Welcome" youtube="1.0:p2Q6BrNhdh8"/>
</chapter>
<chapter url_name="Ch2">
<html url_name="test_html">
<h2>Welcome</h2>
</html>
</chapter>
</course>

View File

@@ -0,0 +1,3 @@
<b>Lab 2A: Superposition Experiment</b>
<p>Isn't the toy course great?</p>

View File

@@ -0,0 +1 @@
<html filename="toylab.html"/>

View File

@@ -0,0 +1,42 @@
{
"course/2012_Fall": {
"graceperiod": "2 days 5 hours 59 minutes 59 seconds",
"start": "2011-07-17T12:00",
"display_name": "Toy Course",
"testcenter_info": {
"Midterm_Exam": {
"Exam_Series_Code": "Midterm_Exam",
"First_Eligible_Appointment_Date": "2012-11-09T00:00",
"Last_Eligible_Appointment_Date": "2012-11-09T23:59"
},
"Final_Exam": {
"Exam_Series_Code": "mit6002xfall12a",
"Exam_Display_Name": "Final Exam",
"First_Eligible_Appointment_Date": "2013-01-25T00:00",
"Last_Eligible_Appointment_Date": "2013-01-25T23:59",
"Registration_Start_Date": "2013-01-01T00:00",
"Registration_End_Date": "2013-01-21T23:59"
}
}
},
"chapter/Overview": {
"display_name": "Overview"
},
"chapter/Ch2": {
"display_name": "Chapter 2",
"start": "2015-07-17T12:00"
},
"videosequence/Toy_Videos": {
"display_name": "Toy Videos",
"format": "Lecture Sequence"
},
"html/toylab": {
"display_name": "Toy lab"
},
"video/Video_Resources": {
"display_name": "Video Resources"
},
"video/Welcome": {
"display_name": "Welcome"
}
}

View File

@@ -0,0 +1 @@
<course org="edX" course="test_start_date" url_name="2012_Fall"/>