diff --git a/common/djangoapps/student/management/commands/pearson_export_cdd.py b/common/djangoapps/student/management/commands/pearson_export_cdd.py index b10e92d92d..67230c7f74 100644 --- a/common/djangoapps/student/management/commands/pearson_export_cdd.py +++ b/common/djangoapps/student/management/commands/pearson_export_cdd.py @@ -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 = '' + option_list = BaseCommand.option_list + ( + make_option( + '--dump_all', + action='store_true', + dest='dump_all', + ), + ) + + args = '' 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() - - - \ No newline at end of file diff --git a/common/djangoapps/student/management/commands/pearson_export_ead.py b/common/djangoapps/student/management/commands/pearson_export_ead.py index 415f0812ae..de3bfc04ee 100644 --- a/common/djangoapps/student/management/commands/pearson_export_ead.py +++ b/common/djangoapps/student/management/commands/pearson_export_ead.py @@ -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 = '' + + 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 = '' 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() - - - \ No newline at end of file diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_registration.py b/common/djangoapps/student/management/commands/pearson_make_tc_registration.py new file mode 100644 index 0000000000..81a478d19d --- /dev/null +++ b/common/djangoapps/student/management/commands/pearson_make_tc_registration.py @@ -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 = "" + 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." + + diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_user.py b/common/djangoapps/student/management/commands/pearson_make_tc_user.py index d974c25b6b..da9bfc3bd0 100644 --- a/common/djangoapps/student/management/commands/pearson_make_tc_user.py +++ b/common/djangoapps/student/management/commands/pearson_make_tc_user.py @@ -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 = "" - 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." + diff --git a/common/djangoapps/student/migrations/0021_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py b/common/djangoapps/student/migrations/0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py similarity index 100% rename from common/djangoapps/student/migrations/0021_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py rename to common/djangoapps/student/migrations/0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py diff --git a/common/djangoapps/student/migrations/0023_add_test_center_registration.py b/common/djangoapps/student/migrations/0023_add_test_center_registration.py new file mode 100644 index 0000000000..c5af38dd37 --- /dev/null +++ b/common/djangoapps/student/migrations/0023_add_test_center_registration.py @@ -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'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 5311e49844..8220e5507c 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 39805fd85f..8696c2ba28 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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 diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 163e40b343..499247cc2d 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -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 diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index 7605155a6c..f92d58db03 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -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") diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index bb5b44c67f..9ad36f633d 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -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 diff --git a/common/test/data/test_exam_registration/README.md b/common/test/data/test_exam_registration/README.md new file mode 100644 index 0000000000..5097fe8798 --- /dev/null +++ b/common/test/data/test_exam_registration/README.md @@ -0,0 +1,2 @@ +Simple course with test center exam information included in policy.json. + diff --git a/common/test/data/test_exam_registration/course.xml b/common/test/data/test_exam_registration/course.xml new file mode 120000 index 0000000000..49041310f6 --- /dev/null +++ b/common/test/data/test_exam_registration/course.xml @@ -0,0 +1 @@ +roots/2012_Fall.xml \ No newline at end of file diff --git a/common/test/data/test_exam_registration/course/2012_Fall.xml b/common/test/data/test_exam_registration/course/2012_Fall.xml new file mode 100644 index 0000000000..77eca9f46c --- /dev/null +++ b/common/test/data/test_exam_registration/course/2012_Fall.xml @@ -0,0 +1,15 @@ + + + + + + + + +

Welcome

+ +
+ +
diff --git a/common/test/data/test_exam_registration/html/toylab.html b/common/test/data/test_exam_registration/html/toylab.html new file mode 100644 index 0000000000..81df84bd63 --- /dev/null +++ b/common/test/data/test_exam_registration/html/toylab.html @@ -0,0 +1,3 @@ +Lab 2A: Superposition Experiment + +

Isn't the toy course great?

diff --git a/common/test/data/test_exam_registration/html/toylab.xml b/common/test/data/test_exam_registration/html/toylab.xml new file mode 100644 index 0000000000..ab78aeb494 --- /dev/null +++ b/common/test/data/test_exam_registration/html/toylab.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/common/test/data/test_exam_registration/policies/2012_Fall.json b/common/test/data/test_exam_registration/policies/2012_Fall.json new file mode 100644 index 0000000000..49af7d1527 --- /dev/null +++ b/common/test/data/test_exam_registration/policies/2012_Fall.json @@ -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" + } +} diff --git a/common/test/data/test_exam_registration/roots/2012_Fall.xml b/common/test/data/test_exam_registration/roots/2012_Fall.xml new file mode 100644 index 0000000000..30dd5e0180 --- /dev/null +++ b/common/test/data/test_exam_registration/roots/2012_Fall.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lms/envs/common.py b/lms/envs/common.py index 6790e5b714..330c8fd304 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -340,6 +340,11 @@ STAFF_GRADING_INTERFACE = { # Used for testing, debugging MOCK_STAFF_GRADING = False +################################# Pearson TestCenter config ################ + +PEARSONVUE_SIGNINPAGE_URL = "https://www1.pearsonvue.com/testtaker/signin/SignInPage/EDX" +# TESTCENTER_ACCOMMODATION_REQUEST_EMAIL = "exam-help@edx.org" + ################################# Peer grading config ##################### #By setting up the default settings with an incorrect user name and password, diff --git a/lms/static/sass/application.scss b/lms/static/sass/application.scss index cfdc1d4b0e..d1dd3d1d4e 100644 --- a/lms/static/sass/application.scss +++ b/lms/static/sass/application.scss @@ -19,6 +19,7 @@ @import 'multicourse/home'; @import 'multicourse/dashboard'; +@import 'multicourse/testcenter-register'; @import 'multicourse/courses'; @import 'multicourse/course_about'; @import 'multicourse/jobs'; diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 458888b658..c22bc14105 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -267,13 +267,12 @@ } .my-course { - @include border-radius(3px); - @include box-shadow(0 1px 8px 0 rgba(0,0,0, 0.1), inset 0 -1px 0 0 rgba(255,255,255, 0.8), inset 0 1px 0 0 rgba(255,255,255, 0.8)); + clear: both; @include clearfix; - height: 120px; margin-right: flex-gutter(); - margin-bottom: 10px; - overflow: hidden; + margin-bottom: 50px; + padding-bottom: 50px; + border-bottom: 1px solid $light-gray; position: relative; width: flex-grid(12); z-index: 20; @@ -283,13 +282,7 @@ margin-bottom: none; } - .cover { - background: rgb(225,225,225); - background-size: cover; - background-position: center center; - border: 1px solid rgb(120,120,120); - @include border-left-radius(3px); - @include box-shadow(inset 0 0 0 1px rgba(255,255,255, 0.6), 1px 0 0 0 rgba(255,255,255, 0.8)); + .cover { @include box-sizing(border-box); float: left; height: 100%; @@ -299,100 +292,51 @@ position: relative; @include transition(all, 0.15s, linear); width: 200px; + height: 120px; - .shade { - @include background-image(linear-gradient(-90deg, rgba(255,255,255, 0.3) 0%, - rgba(0,0,0, 0.3) 100%)); - bottom: 0px; - content: ""; - display: block; - left: 0px; - position: absolute; - z-index: 50; - top: 0px; - @include transition(all, 0.15s, linear); - right: 0px; - } - - .arrow { - position: absolute; - z-index: 100; + img { width: 100%; - font-size: 70px; - line-height: 110px; - text-align: center; - text-decoration: none; - color: rgba(0, 0, 0, .7); - opacity: 0; - @include transition(all, 0.15s, linear); - } - - &:hover { - .shade { - background: rgba(255,255,255, 0.3); - @include background-image(linear-gradient(-90deg, rgba(255,255,255, 0.3) 0%, - rgba(0,0,0, 0.3) 100%)); - } } } .info { - background: rgb(250,250,250); - @include background-image(linear-gradient(-90deg, rgb(253,253,253), rgb(240,240,240))); - @include box-sizing(border-box); - border: 1px solid rgb(190,190,190); - border-left: none; - @include border-right-radius(3px); - left: 201px; - height: 100%; - max-height: 100%; - padding: 0px 10px; - position: absolute; - right: 0px; - top: 0px; - z-index: 2; + @include clearfix; + padding: 0 10px 0 230px; > hgroup { - @include clearfix; - border-bottom: 1px solid rgb(210,210,210); - @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); - padding: 12px 0px; + padding: 0; width: 100%; .university { - background: rgba(255,255,255, 1); - border: 1px solid rgb(180,180,180); - @include border-radius(3px); - @include box-shadow(inset 0 0 3px 0 rgba(0,0,0, 0.2), 0 1px 0 0 rgba(255,255,255, 0.6)); color: $lighter-base-font-color; - display: block; - font-style: italic; font-family: $sans-serif; font-size: 16px; - font-weight: 800; - @include inline-block; - margin-right: 10px; - margin-bottom: 0; - padding: 5px 10px; - float: left; + font-weight: 400; + margin: 0 0 6px; + text-transform: none; + letter-spacing: 0; } - h3 { + .date-block { + position: absolute; + top: 0; + right: 0; + font-family: $sans-serif; + font-size: 13px; + font-style: italic; + color: $lighter-base-font-color; + } + + h3 a { display: block; - margin-bottom: 0px; - overflow: hidden; - padding-top: 2px; - text-overflow: ellipsis; - white-space: nowrap; + margin-bottom: 10px; + font-family: $sans-serif; + font-size: 34px; + line-height: 42px; + font-weight: 300; - a { - color: $base-font-color; - font-weight: 700; - text-shadow: 0 1px rgba(255,255,255, 0.6); - - &:hover { - text-decoration: underline; - } + &:hover { + text-decoration: none; } } } @@ -430,71 +374,56 @@ } .enter-course { - @include button(shiny, $blue); + @include button(simple, $blue); @include box-sizing(border-box); @include border-radius(3px); display: block; float: left; - font: normal 1rem/1.6rem $sans-serif; - letter-spacing: 1px; - padding: 6px 0px; - text-transform: uppercase; + font: normal 15px/1.6rem $sans-serif; + letter-spacing: 0; + padding: 6px 32px 7px; text-align: center; margin-top: 16px; - width: flex-grid(4); - } - } - > a:hover { - .cover { - .shade { - background: rgba(255,255,255, 0.1); - @include background-image(linear-gradient(-90deg, rgba(255,255,255, 0.3) 0%, - rgba(0,0,0, 0.3) 100%)); + &.archived { + @include button(simple, #eee); + font: normal 15px/1.6rem $sans-serif; + padding: 6px 32px 7px; + + &:hover { + text-decoration: none; + } } - .arrow { - opacity: 1; - } - } - - .info { - background: darken(rgb(250,250,250), 5%); - @include background-image(linear-gradient(-90deg, darken(rgb(253,253,253), 3%), darken(rgb(240,240,240), 5%))); - border-color: darken(rgb(190,190,190), 10%); - - .course-status { - background: darken($yellow, 3%); - border-color: darken(rgb(200,200,200), 3%); - @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); - } - - .course-status-completed { - background: #888; - color: #fff; + &:hover { + text-decoration: none; } } } } .message-status { + @include clearfix; @include border-radius(3px); - @include box-shadow(0 1px 4px 0 rgba(0,0,0, 0.1), inset 0 -1px 0 0 rgba(255,255,255, 0.8), inset 0 1px 0 0 rgba(255,255,255, 0.8)); display: none; - position: relative; - top: -15px; z-index: 10; - margin: 0 0 20px 0; + margin: 20px 0 10px; padding: 15px 20px; - font-family: "Open Sans", Verdana, Geneva, sans-serif; - background: #fffcf0; + font-family: $sans-serif; + background: tint($yellow,70%); border: 1px solid #ccc; .message-copy { + font-family: $sans-serif; + font-size: 13px; margin: 0; + a { + font-family: $sans-serif; + } + .grade-value { - font-size: 1.4rem; + font-size: 1.2rem; font-weight: bold; } } @@ -502,19 +431,18 @@ .actions { @include clearfix; list-style: none; - margin: 15px 0 0 0; + margin: 0; padding: 0; .action { float: left; - margin:0 15px 10px 0; + margin: 0 15px 0 0; .btn, .cta { display: inline-block; } .btn { - @include button(shiny, $blue); @include box-sizing(border-box); @include border-radius(3px); float: left; @@ -524,7 +452,6 @@ text-align: center; &.disabled { - @include button(shiny, #eee); cursor: default !important; &:hover { @@ -539,7 +466,6 @@ } .cta { - @include button(shiny, #666); float: left; font: normal 0.8rem/1.2rem $sans-serif; letter-spacing: 1px; @@ -549,6 +475,52 @@ } } + .exam-registration-number { + font-family: $sans-serif; + font-size: 18px; + + a { + font-family: $sans-serif; + } + } + + &.exam-register { + + .message-copy { + margin-top: 5px; + width: 55%; + } + } + + &.exam-schedule { + .exam-button { + margin-top: 5px; + } + } + + .exam-button { + @include button(simple, $pink); + margin-top: 0; + float: right; + } + + .contact-button { + @include button(simple, $pink); + } + + .button { + display: inline-block; + margin-top: 10px; + padding: 9px 18px 10px; + font-size: 13px; + font-weight: bold; + letter-spacing: 0; + + &:hover { + text-decoration: none; + } + } + &.is-shown { display: block; } @@ -577,17 +549,16 @@ a.unenroll { float: right; + display: block; font-style: italic; color: #a0a0a0; text-decoration: underline; font-size: .8em; - @include inline-block; - margin-bottom: 40px; + margin-top: 32px; &:hover { color: #333; } } - } } diff --git a/lms/static/sass/multicourse/_testcenter-register.scss b/lms/static/sass/multicourse/_testcenter-register.scss new file mode 100644 index 0000000000..961fffd5d0 --- /dev/null +++ b/lms/static/sass/multicourse/_testcenter-register.scss @@ -0,0 +1,794 @@ +// ========== + +$baseline: 20px; +$yellow: rgb(255, 235, 169); +$red: rgb(178, 6, 16); + +// ========== + +.testcenter-register { + @include clearfix; + padding: 60px 0px 120px; + + // reset - horrible, but necessary + p, a, h1, h2, h3, h4, h5, h6 { + font-family: $sans-serif !important; + } + + // basic layout + .introduction { + width: flex-grid(12); + } + + .message-status-registration { + width: flex-grid(12); + } + + .content, aside { + @include box-sizing(border-box); + } + + .content { + margin-right: flex-gutter(); + width: flex-grid(8); + float: left; + } + + aside { + margin: 0; + width: flex-grid(4); + float: left; + } + + // introduction + .introduction { + + header { + + h2 { + margin: 0; + font-family: $sans-serif; + font-size: 16px; + color: $lighter-base-font-color; + } + + h1 { + font-family: $sans-serif; + font-size: 34px; + text-align: left; + } + } + } + + // content + .content { + background: rgb(255,255,255); + } + + // form + .form-fields-primary, .form-fields-secondary { + border-bottom: 1px solid rgba(0,0,0,0.25); + @include box-shadow(0 1px 2px 0 rgba(0,0,0, 0.1)); + } + + form { + border: 1px solid rgb(216, 223, 230); + @include border-radius(3px); + @include box-shadow(0 1px 2px 0 rgba(0,0,0, 0.2)); + + .instructions, .note { + margin: 0; + padding: ($baseline*1.5) ($baseline*1.5) 0 ($baseline*1.5); + font-size: 14px; + color: tint($base-font-color, 20%); + + strong { + font-weight: normal; + } + + .title, .indicator { + color: $base-font-color; + font-weight: 700; + } + } + + fieldset { + border-bottom: 1px solid rgba(216, 223, 230, 0.50); + padding: ($baseline*1.5); + } + + .form-actions { + @include clearfix(); + padding: ($baseline*1.5); + + button[type="submit"] { + display: block; + @include button(simple, $blue); + @include box-sizing(border-box); + @include border-radius(3px); + font: bold 15px/1.6rem $sans-serif; + letter-spacing: 0; + padding: ($baseline*0.75) $baseline; + text-align: center; + + + &:disabled { + opacity: 0.3; + } + } + + .action-primary { + float: left; + width: flex-grid(5,8); + margin-right: flex-gutter(2); + } + + .action-secondary { + display: block; + float: left; + width: flex-grid(2,8); + margin-top: $baseline; + padding: ($baseline/4); + font-size: 13px; + text-align: right; + text-transform: uppercase; + } + + &.error { + + } + } + + .list-input { + margin: 0; + padding: 0; + list-style: none; + + .field { + border-bottom: 1px dotted rgba(216, 223, 230, 0.5); + margin: 0 0 $baseline 0; + padding: 0 0 $baseline 0; + + &:last-child { + border: none; + margin-bottom: 0; + padding-bottom: 0; + } + + &.disabled, &.submitted { + color: rgba(0,0,0,.25); + + label { + cursor: text; + + &:after { + margin-left: ($baseline/4); + } + } + + textarea, input { + background: rgb(255,255,255); + color: rgba(0,0,0,.25); + } + } + + &.disabled { + label:after { + color: rgba(0,0,0,.35); + content: "(Disabled Currently)"; + } + } + + &.submitted { + + label:after { + content: "(Previously Submitted and Not Editable)"; + } + + .value { + @include border-radius(3px); + border: 1px solid #C8C8C8; + padding: $baseline ($baseline*0.75); + background: #FAFAFA; + } + } + + &.error { + + label { + color: $red; + } + + input, textarea { + border-color: tint($red,50%); + } + } + + &.required { + + label { + font-weight: bold; + } + + label:after { + margin-left: ($baseline/4); + content: "*"; + } + } + + label, input, textarea { + display: block; + font-family: $sans-serif; + font-style: normal; + } + + label { + margin: 0 0 ($baseline/4) 0; + @include transition(color, 0.15s, ease-in-out); + + &.is-focused { + color: $blue; + } + } + + input, textarea { + width: 100%; + padding: $baseline ($baseline*.75); + + &.long { + width: 100%; + } + + &.short { + width: 25%; + } + } + + textarea.long { + height: ($baseline*5); + } + } + + .field-group { + @include clearfix(); + border-bottom: 1px dotted rgba(216, 223, 230, 0.5); + margin: 0 0 $baseline 0; + padding: 0 0 $baseline 0; + + .field { + display: block; + float: left; + border-bottom: none; + margin: 0 $baseline ($baseline/2) 0; + padding-bottom: 0; + + input, textarea { + width: 100%; + } + } + + &.addresses { + + .field { + width: 45%; + } + } + + &.postal-2 { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; + + } + + &.phoneinfo { + + } + } + } + + &.disabled { + + > .instructions { + display: none; + } + + .field { + opacity: 0.6; + + .label, label { + cursor: auto; + } + } + + .form-actions { + display: none; + } + } + } + + // form - specifics + .form-fields-secondary { + display: none; + + &.is-shown { + display: block; + } + + &.disabled { + + fieldset { + opacity: 0.5; + } + } + } + + .form-fields-secondary-visibility { + display: block; + margin: 0; + padding: $baseline ($baseline*1.5) 0 ($baseline*1.5); + font-size: 13px; + + &.is-hidden { + display: none; + } + } + + + // aside + aside { + padding-left: $baseline; + + .message-status { + @include border-radius(3px); + margin: 0 0 ($baseline*2) 0; + border: 1px solid #ccc; + padding: 0; + background: tint($yellow,90%); + + > * { + padding: $baseline; + } + + p { + margin: 0 0 ($baseline/4) 0; + padding: 0; + font-size: 13px; + } + + .label, .value { + display: block; + } + + h3, h4, h5 { + font-family: $sans-serif; + } + + h3 { + border-bottom: 1px solid tint(rgb(0,0,0), 90%); + padding-bottom: ($baseline*0.75); + } + + h4 { + margin-bottom: ($baseline/4); + } + + .status-list { + list-style: none; + margin: 0; + padding: $baseline; + + > .item { + @include clearfix(); + margin: 0 0 ($baseline*0.75) 0; + border-bottom: 1px solid tint(rgb(0,0,0), 95%); + padding: 0 0 ($baseline/2) 0; + + &:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: 0; + } + + .title { + margin-bottom: ($baseline/4); + position: relative; + font-weight: bold; + font-size: 14px; + + &:after { + position: absolute; + top: 0; + right: $baseline; + margin-left: $baseline; + content: "not started"; + text-transform: uppercase; + font-size: 11px; + font-weight: normal; + opacity: 0.5; + } + } + + .details, .item, .instructions { + @include transition(opacity, 0.10s, ease-in-out); + font-size: 13px; + opacity: 0.65; + } + + &:before { + @include border-radius($baseline); + position: relative; + top: 3px; + display: block; + float: left; + width: ($baseline/2); + height: ($baseline/2); + margin: 0 ($baseline/2) 0 0; + background: $dark-gray; + content: ""; + } + + // specific states + &.status-processed { + + &:before { + background: green; + } + + .title:after { + color: green; + content: "processed"; + } + + &.status-registration { + .exam-link { + font-weight: 600 !important; + } + } + } + + &.status-pending { + + &:before { + background: transparent; + border: 1px dotted gray; + } + + .title:after { + color: gray; + content: "pending"; + } + } + + &.status-rejected { + + &:before { + background: $red; + } + + .title:after { + color: red; + content: "rejected"; + } + + .call-link { + font-weight: bold; + } + } + + &.status-initial { + + &:before { + background: transparent; + border: 1px dotted gray; + } + + .title:after { + color: gray; + } + } + + &:hover { + + .details, .item, .instructions { + opacity: 1.0; + } + } + } + + // sub menus + .accommodations-list, .error-list { + list-style: none; + margin: ($baseline/2) 0; + padding: 0; + font-size: 13px; + + .item { + margin: 0 0 ($baseline/4) 0; + padding: 0; + } + } + } + + // actions + .contact-link { + font-weight: 600; + } + + .actions { + @include box-shadow(inset 0 1px 1px 0px rgba(0,0,0,0.2)); + border-top: 1px solid tint(rgb(0,0,0), 90%); + padding-top: ($baseline*0.75); + background: tint($yellow,70%); + font-size: 14px; + + .title { + font-size: 14px; + } + + .label, .value { + display: inline-block; + } + + .label { + margin-right: ($baseline/4); + } + + .value { + font-weight: bold; + } + + .message-copy { + font-size: 13px; + } + + .exam-button { + @include button(simple, $pink); + display: block; + margin: ($baseline/2) 0 0 0; + padding: ($baseline/2) $baseline; + font-size: 13px; + font-weight: bold; + + &:hover { + text-decoration: none; + } + } + } + + .registration-number { + + .label { + text-transform: none; + letter-spacing: 0; + } + + + } + + .registration-processed { + + .message-copy { + margin: 0 0 ($baseline/2) 0; + } + } + } + + > .details { + border-bottom: 1px solid rgba(216, 223, 230, 0.5); + margin: 0 0 $baseline 0; + padding: 0 $baseline $baseline $baseline; + font-family: $sans-serif; + font-size: 14px; + + &:last-child { + border: none; + margin-bottom: 0; + padding-bottom: 0; + } + + h4 { + margin: 0 0 ($baseline/2) 0; + font-family: $sans-serif; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #ccc; + } + + .label, .value { + display: inline-block; + } + + .label { + color: rgba(0,0,0,.65); + margin-right: ($baseline/2); + } + + .value { + color: rgb(0,0,0); + font-weight: 600; + } + } + + .details-course { + + } + + .details-registration { + + ul { + margin: 0; + padding: 0; + list-style: none; + + li { + margin: 0 0 ($baseline/4) 0; + } + } + } + } + + // status messages + .message { + @include border-radius(3px); + display: none; + margin: $baseline 0; + padding: ($baseline/2) $baseline; + + &.is-shown { + display: block; + } + + .message-copy { + font-size: 14px; + } + + // registration status + &.message-flash { + @include border-radius(3px); + position: relative; + margin: 0 0 ($baseline*2) 0; + border: 1px solid #ccc; + padding-top: ($baseline*0.75); + background: tint($yellow,70%); + font-size: 14px; + + .message-title, .message-copy { + } + + .message-title { + font-weight: bold; + font-size: 16px; + margin: 0 0 ($baseline/4) 0; + } + + .message-copy { + font-size: 14px; + } + + .contact-button { + @include button(simple, $blue); + } + + .exam-button { + @include button(simple, $pink); + } + + .button { + position: absolute; + top: ($baseline/4); + right: $baseline; + margin: ($baseline/2) 0 0 0; + padding: ($baseline/2) $baseline; + font-size: 13px; + font-weight: bold; + letter-spacing: 0; + + &:hover { + text-decoration: none; + } + } + + &.message-action { + + .message-title, .message-copy { + width: 65%; + } + } + } + + // submission error + &.submission-error { + @include box-sizing(border-box); + float: left; + width: flex-grid(8,8); + border: 1px solid tint($red,85%); + background: tint($red,95%); + font-size: 14px; + + #submission-error-heading { + margin-bottom: ($baseline/2); + border-bottom: 1px solid tint($red, 85%); + padding-bottom: ($baseline/2); + font-weight: bold; + } + + .field-name, .field-error { + display: inline-block; + } + + .field-name { + margin-right: ($baseline/4); + } + + .field-error { + color: tint($red, 55%); + } + + p { + color: $red; + } + + ul { + margin: 0 0 ($baseline/2) 0; + padding: 0; + list-style: none; + + li { + margin-bottom: ($baseline/2); + padding: 0; + + span { + color: $red; + } + + a { + color: $red; + text-decoration: none; + + &:hover, &:active { + text-decoration: underline; + } + } + } + } + } + + // submission success + &.submission-saved { + border: 1px solid tint($blue,85%); + background: tint($blue,95%); + + .message-copy { + color: $blue; + } + } + + // specific - registration closed + &.registration-closed { + @include border-bottom-radius(0); + margin-top: 0; + border-bottom: 1px solid $light-gray; + padding: $baseline; + background: tint($light-gray,50%); + + .message-title { + font-weight: bold; + } + + .message-copy { + + } + } + } + + .is-shown { + display: block; + } + + // hidden + .is-hidden { + display: none; + } +} \ No newline at end of file diff --git a/lms/static/sass/shared/_forms.scss b/lms/static/sass/shared/_forms.scss index d6a5f482e3..79d476f420 100644 --- a/lms/static/sass/shared/_forms.scss +++ b/lms/static/sass/shared/_forms.scss @@ -13,7 +13,8 @@ label { textarea, input[type="text"], input[type="email"], -input[type="password"] { +input[type="password"], +input[type="tel"] { background: rgb(250,250,250); border: 1px solid rgb(200,200,200); @include border-radius(3px); diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index d9b57ac044..0182a8edf1 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -198,87 +198,132 @@ course_target = reverse('about_course', args=[course.id]) %> - -
-
-
-
-
-
-

${get_course_about_section(course, 'university')}

-

${course.number} ${course.title}

-
-
-

+ + + + + +

+
+

% if course.has_ended(): - Course Completed - ${course.end_date_text} + Course Completed - ${course.end_date_text} % elif course.has_started(): - Course Started - ${course.start_date_text} + Course Started - ${course.start_date_text} % else: # hasn't started yet - Course Starts - ${course.start_date_text} + Course Starts - ${course.start_date_text} % endif

-
- % if course.id in show_courseware_links_for: -

View Courseware

- % endif -
- +

${get_course_about_section(course, 'university')}

+

${course.number} ${course.title}

+ + + <% + testcenter_exam_info = course.current_test_center_exam + registration = exam_registrations.get(course.id) + testcenter_register_target = reverse('begin_exam_registration', args=[course.id]) + %> + % if testcenter_exam_info is not None: + + % if registration is None and testcenter_exam_info.is_registering(): +
+ Register for Pearson exam +

Registration for the Pearson exam is now open and will close on ${testcenter_exam_info.registration_end_date_text}

+
+ % endif + + % if registration is not None: + % if registration.is_accepted: +
+ Schedule Pearson exam +

Registration number: ${registration.client_candidate_id}

+

Write this down! You’ll need it to schedule your exam.

+
+ % endif + % if registration.is_rejected: +
+

Your + registration for the Pearson exam + has been rejected. Please check the information you provided, and try to correct any demographic errors. Otherwise + contact edX for further help.

+ Contact exam-help@edx.org +
+ % endif + % if not registration.is_accepted and not registration.is_rejected: +
+

Your + registration for the Pearson exam + is pending. Within a few days, you should see a confirmation number here, which can be used to schedule your exam.

+
+ % endif + % endif + + % endif + + <% + cert_status = cert_statuses.get(course.id) + %> + % if course.has_ended() and cert_status: + <% + if cert_status['status'] == 'generating': + status_css_class = 'course-status-certrendering' + elif cert_status['status'] == 'ready': + status_css_class = 'course-status-certavailable' + elif cert_status['status'] == 'notpassing': + status_css_class = 'course-status-certnotavailable' + else: + status_css_class = 'course-status-processing' + %> +
+ + % if cert_status['status'] == 'processing': +

Final course details are being wrapped up at + this time. Your final standing will be available shortly.

+ % elif cert_status['status'] in ('generating', 'ready', 'notpassing'): +

Your final grade: + ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. + % if cert_status['status'] == 'notpassing': + Grade required for a certificate: + ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}. + % endif +

+ % endif + + % if cert_status['show_disabled_download_button'] or cert_status['show_download_url'] or cert_status['show_survey_button']: + + % endif +
+ + % endif + + % if course.id in show_courseware_links_for: + % if course.has_ended(): + View Archived Course + % else: + View Course + % endif + % endif + Unregister +
- <% - cert_status = cert_statuses.get(course.id) - %> - % if course.has_ended() and cert_status: - <% - if cert_status['status'] == 'generating': - status_css_class = 'course-status-certrendering' - elif cert_status['status'] == 'ready': - status_css_class = 'course-status-certavailable' - elif cert_status['status'] == 'notpassing': - status_css_class = 'course-status-certnotavailable' - else: - status_css_class = 'course-status-processing' - %> -
- - % if cert_status['status'] == 'processing': -

Final course details are being wrapped up at - this time. Your final standing will be available shortly.

- % elif cert_status['status'] in ('generating', 'ready', 'notpassing'): -

Your final grade: - ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. - % if cert_status['status'] == 'notpassing': - Grade required for a certificate: - ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}. - % endif -

- % endif - - % if cert_status['show_disabled_download_button'] or cert_status['show_download_url'] or cert_status['show_survey_button']: - - % endif -
- - % endif - - Unregister + % endfor % else: diff --git a/lms/templates/test_center_register.html b/lms/templates/test_center_register.html new file mode 100644 index 0000000000..03883d907c --- /dev/null +++ b/lms/templates/test_center_register.html @@ -0,0 +1,480 @@ +<%! + from django.core.urlresolvers import reverse + from courseware.courses import course_image_url, get_course_about_section + from courseware.access import has_access + from certificates.models import CertificateStatuses +%> +<%inherit file="main.html" /> + +<%namespace name='static' file='static_content.html'/> + +<%block name="title">Pearson VUE Test Center Proctoring - Registration + +<%block name="js_extra"> + + + +
+ +
+
+
+

${get_course_about_section(course, 'university')} ${course.number} ${course.title}

+ + % if registration: +

Your Pearson VUE Proctored Exam Registration

+ % else: +

Register for a Pearson VUE Proctored Exam

+ % endif +
+
+
+ + <% + exam_help_href = "mailto:exam-help@edx.org?subject=Pearson VUE Exam - " + get_course_about_section(course, 'university') + " - " + course.number + %> + + % if registration: + + % if registration.is_accepted: +
+

Your registration for the Pearson exam has been processed

+

Your registration number is ${registration.client_candidate_id}. (Write this down! You’ll need it to schedule your exam.)

+ Schedule Pearson exam +
+ % endif + + % if registration.demographics_is_rejected: +
+

Your demographic information contained an error and was rejected

+

Please check the information you provided, and correct the errors noted below. +

+ % endif + + % if registration.registration_is_rejected: +
+

Your registration for the Pearson exam has been rejected

+

Please see your registration status details for more information.

+
+ % endif + + % if registration.is_pending: +
+

Your registration for the Pearson exam is pending

+

Once your information is processed, it will be forwarded to Pearson and you will be able to schedule an exam.

+
+ % endif + + % endif + +
+
+ +
+ % if exam_info.is_registering(): +
+ % else: + + +
+

Registration for this Pearson exam is closed

+

Your previous information is available below, however you may not edit any of the information. +

+ % endif + + % if registration: +

+ Please use the following form if you need to update your demographic information used in your Pearson VUE Proctored Exam. Required fields are noted by bold text and an asterisk (*). +

+ % else: +

+ Please provide the following demographic information to register for a Pearson VUE Proctored Exam. Required fields are noted by bold text and an asterisk (*). +

+ % endif + + + + + + + +
+
+ + +
    +
  1. + + +
  2. +
  3. + + +
  4. +
  5. + + +
  6. +
  7. + + +
  8. +
  9. + + +
  10. +
+
+ +
+ + +
    +
  1. + + +
  2. +
  3. +
    + + +
    +
    + + +
    +
  4. +
  5. + + +
  6. +
  7. +
    + + +
    +
    + + +
    +
    + + +
    +
  8. +
+
+ +
+ + +
    +
  1. +
    + + +
    +
    + + +
    +
    + + +
    +
  2. +
  3. +
    + + +
    +
    + + +
    +
  4. +
  5. + + +
  6. +
+
+
+ + % if registration: + % if registration.accommodation_request and len(registration.accommodation_request) > 0: +
+ % endif + % else: +
+ % endif + + % if registration: + % if registration.accommodation_request and len(registration.accommodation_request) > 0: +

Note: Your previous accommodation request below needs to be reviewed in detail and will add a significant delay to your registration process.

+ % endif + % else: +

Note: Accommodation requests are not part of your demographic information, and cannot be changed once submitted. Accommodation requests, which are reviewed on a case-by-case basis, will add significant delay to the registration process.

+ % endif + +
+ + +
    + % if registration: + % if registration.accommodation_request and len(registration.accommodation_request) > 0: + + % endif + % else: +
  1. + + +
  2. + % endif +
+
+
+ +
+ % if registration: + + Cancel Update + % else: + + Cancel Registration + % endif + +
+

+
    +
    +
    + + + % if registration: + % if registration.accommodation_request and len(registration.accommodation_request) > 0: + + % endif + % else: + Special (ADA) Accommodations + % endif +
    + + +
    diff --git a/lms/urls.py b/lms/urls.py index ae2b727616..cab0533f89 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -45,6 +45,9 @@ urlpatterns = ('', url(r'^create_account$', 'student.views.create_account'), url(r'^activate/(?P[^/]*)$', 'student.views.activate_account', name="activate"), + url(r'^begin_exam_registration/(?P[^/]+/[^/]+/[^/]+)$', 'student.views.begin_exam_registration', name="begin_exam_registration"), + url(r'^create_exam_registration$', 'student.views.create_exam_registration'), + url(r'^password_reset/$', 'student.views.password_reset', name='password_reset'), ## Obsolete Django views for password resets ## TODO: Replace with Mako-ized views