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_remove_askbot.py b/common/djangoapps/student/migrations/0021_remove_askbot.py index 89f7208f40..83ad6791f2 100644 --- a/common/djangoapps/student/migrations/0021_remove_askbot.py +++ b/common/djangoapps/student/migrations/0021_remove_askbot.py @@ -26,14 +26,17 @@ class Migration(SchemaMigration): def forwards(self, orm): "Kill the askbot" - # For MySQL, we're batching the alters together for performance reasons - if db.backend_name == 'mysql': - drops = ["drop `{0}`".format(col) for col in ASKBOT_AUTH_USER_COLUMNS] - statement = "alter table `auth_user` {0};".format(", ".join(drops)) - db.execute(statement) - else: - for column in ASKBOT_AUTH_USER_COLUMNS: - db.delete_column('auth_user', column) + try: + # For MySQL, we're batching the alters together for performance reasons + if db.backend_name == 'mysql': + drops = ["drop `{0}`".format(col) for col in ASKBOT_AUTH_USER_COLUMNS] + statement = "alter table `auth_user` {0};".format(", ".join(drops)) + db.execute(statement) + else: + for column in ASKBOT_AUTH_USER_COLUMNS: + db.delete_column('auth_user', column) + except Exception as ex: + print "Couldn't remove askbot because of {0} -- it was probably never here to begin with.".format(ex) def backwards(self, orm): raise RuntimeError("Cannot reverse this migration: there's no going back to Askbot.") 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/timeparse.py b/common/lib/xmodule/xmodule/timeparse.py index 117105d085..36c0f725e5 100644 --- a/common/lib/xmodule/xmodule/timeparse.py +++ b/common/lib/xmodule/xmodule/timeparse.py @@ -7,8 +7,11 @@ TIME_FORMAT = "%Y-%m-%dT%H:%M" def parse_time(time_str): """ - Takes a time string in TIME_FORMAT, returns - it as a time_struct. Raises ValueError if the string is not in the right format. + Takes a time string in TIME_FORMAT + + Returns it as a time_struct. + + Raises ValueError if the string is not in the right format. """ return time.strptime(time_str, TIME_FORMAT) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 19a592191e..a1d9cdda9b 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -414,7 +414,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): 'xqa_key', # TODO: This is used by the XMLModuleStore to provide for locations for # static files, and will need to be removed when that code is removed - 'data_dir' + 'data_dir', + # How many days early to show a course element to beta testers (float) + # intended to be set per-course, but can be overridden in for specific + # elements. Can be a float. + 'days_early_for_beta' ) # cdodge: this is a list of metadata names which are 'system' metadata @@ -497,12 +501,26 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): @property def start(self): """ - If self.metadata contains start, return it. Else return None. + If self.metadata contains a valid start time, return it as a time struct. + Else return None. """ if 'start' not in self.metadata: return None return self._try_parse_time('start') + @property + def days_early_for_beta(self): + """ + If self.metadata contains start, return the number, as a float. Else return None. + """ + if 'days_early_for_beta' not in self.metadata: + return None + try: + return float(self.metadata['days_early_for_beta']) + except ValueError: + return None + + @property def own_metadata(self): """ @@ -715,7 +733,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): """ Parse an optional metadata key containing a time: if present, complain if it doesn't parse. - Return None if not present or invalid. + + Returns a time_struct, or None if metadata key is not present or is invalid. """ if key in self.metadata: try: 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/doc/xml-format.md b/doc/xml-format.md index d7c5027a79..8138de4d7e 100644 --- a/doc/xml-format.md +++ b/doc/xml-format.md @@ -257,6 +257,7 @@ Supported fields at the course level: * "tabs" -- have custom tabs in the courseware. See below for details on config. * "discussion_blackouts" -- An array of time intervals during which you want to disable a student's ability to create or edit posts in the forum. Moderators, Community TAs, and Admins are unaffected. You might use this during exam periods, but please be aware that the forum is often a very good place to catch mistakes and clarify points to students. The better long term solution would be to have better flagging/moderation mechanisms, but this is the hammer we have today. Format by example: [["2012-10-29T04:00", "2012-11-03T04:00"], ["2012-12-30T04:00", "2013-01-02T04:00"]] * "show_calculator" (value "Yes" if desired) +* "days_early_for_beta" -- number of days (floating point ok) early that students in the beta-testers group get to see course content. Can also be specified for any other course element, and overrides values set at higher levels. * TODO: there are others ### Grading policy file contents diff --git a/jenkins/test.sh b/jenkins/test.sh index e960313d76..7d946a24cd 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -28,12 +28,12 @@ export PYTHONIOENCODING=UTF-8 GIT_BRANCH=${GIT_BRANCH/HEAD/master} -# Temporary workaround for pip/numpy bug. (Jenkin's is unable to pip install numpy successfully, scipy fails to install afterwards. -# We tried pip 1.1, 1.2, all sorts of varieties but it's apparently a pip bug of some kind. -wget -O /tmp/numpy.tar.gz http://pypi.python.org/packages/source/n/numpy/numpy-1.6.2.tar.gz#md5=95ed6c9dcc94af1fc1642ea2a33c1bba -tar -zxvf /tmp/numpy.tar.gz -C /tmp/ -python /tmp/numpy-1.6.2/setup.py install +if [ ! -d /mnt/virtualenvs/"$JOB_NAME" ]; then + mkdir -p /mnt/virtualenvs/"$JOB_NAME" + virtualenv /mnt/virtualenvs/"$JOB_NAME" +fi +source /mnt/virtualenvs/"$JOB_NAME"/bin/activate pip install -q -r pre-requirements.txt pip install -q -r test-requirements.txt yes w | pip install -q -r requirements.txt @@ -45,7 +45,6 @@ TESTS_FAILED=0 rake test_lms[false] || TESTS_FAILED=1 rake test_common/lib/capa || TESTS_FAILED=1 rake test_common/lib/xmodule || TESTS_FAILED=1 -rake phantomjs_jasmine_lms || true # Don't run the studio tests until feature/cale/cms-master is merged in # rake phantomjs_jasmine_cms || true rake coverage:xml coverage:html diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 26f9fcdfd3..c7e09526c9 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -4,13 +4,13 @@ like DISABLE_START_DATES""" import logging import time +from datetime import datetime, timedelta from django.conf import settings from xmodule.course_module import CourseDescriptor from xmodule.error_module import ErrorDescriptor from xmodule.modulestore import Location -from xmodule.timeparse import parse_time from xmodule.x_module import XModule, XModuleDescriptor from student.models import CourseEnrollmentAllowed @@ -73,7 +73,7 @@ def has_access(user, obj, action): raise TypeError("Unknown object type in has_access(): '{0}'" .format(type(obj))) -def get_access_group_name(obj,action): +def get_access_group_name(obj, action): ''' Returns group name for user group which has "action" access to the given object. @@ -226,9 +226,10 @@ def _has_access_descriptor(user, descriptor, action): # Check start date if descriptor.start is not None: now = time.gmtime() - if now > descriptor.start: + effective_start = _adjust_start_date_for_beta_testers(user, descriptor) + if now > effective_start: # after start date, everyone can see it - debug("Allow: now > start date") + debug("Allow: now > effective start date") return True # otherwise, need staff access return _has_staff_access_to_descriptor(user, descriptor) @@ -328,6 +329,15 @@ def _course_staff_group_name(location): """ return 'staff_%s' % Location(location).course +def course_beta_test_group_name(location): + """ + Get the name of the beta tester group for a location. Right now, that's + beta_testers_COURSE. + + location: something that can passed to Location. + """ + return 'beta_testers_{0}'.format(Location(location).course) + def _course_instructor_group_name(location): """ @@ -348,6 +358,51 @@ def _has_global_staff_access(user): return False +def _adjust_start_date_for_beta_testers(user, descriptor): + """ + If user is in a beta test group, adjust the start date by the appropriate number of + days. + + Arguments: + user: A django user. May be anonymous. + descriptor: the XModuleDescriptor the user is trying to get access to, with a + non-None start date. + + Returns: + A time, in the same format as returned by time.gmtime(). Either the same as + start, or earlier for beta testers. + + NOTE: number of days to adjust should be cached to avoid looking it up thousands of + times per query. + + NOTE: For now, this function assumes that the descriptor's location is in the course + the user is looking at. Once we have proper usages and definitions per the XBlock + design, this should use the course the usage is in. + + NOTE: If testing manually, make sure MITX_FEATURES['DISABLE_START_DATES'] = False + in envs/dev.py! + """ + if descriptor.days_early_for_beta is None: + # bail early if no beta testing is set up + return descriptor.start + + user_groups = [g.name for g in user.groups.all()] + + beta_group = course_beta_test_group_name(descriptor.location) + if beta_group in user_groups: + debug("Adjust start time: user in group %s", beta_group) + # time_structs don't support subtraction, so convert to datetimes, + # subtract, convert back. + # (fun fact: datetime(*a_time_struct[:6]) is the beautiful syntax for + # converting time_structs into datetimes) + start_as_datetime = datetime(*descriptor.start[:6]) + delta = timedelta(descriptor.days_early_for_beta) + effective = start_as_datetime - delta + # ...and back to time_struct + return effective.timetuple() + + return descriptor.start + def _has_instructor_access_to_location(user, location): return _has_access_to_location(user, location, 'instructor') diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index b6ec6aff02..eeb304b193 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -17,7 +17,8 @@ import xmodule.modulestore.django # Need access to internal func to put users in the right group from courseware import grades -from courseware.access import _course_staff_group_name +from courseware.access import (has_access, _course_staff_group_name, + course_beta_test_group_name) from courseware.models import StudentModuleCache from student.models import Registration @@ -238,7 +239,7 @@ class PageLoader(ActivateLoginTestCase): n = 0 num_bad = 0 all_ok = True - for descriptor in module_store.modules[course_id].itervalues(): + for descriptor in module_store.modules[course_id].itervalues(): n += 1 print "Checking ", descriptor.location.url() #print descriptor.__class__, descriptor.location @@ -259,11 +260,11 @@ class PageLoader(ActivateLoginTestCase): # check content to make sure there were no rendering failures content = resp.content if content.find("this module is temporarily unavailable")>=0: - msg = "ERROR unavailable module " + msg = "ERROR unavailable module " all_ok = False num_bad += 1 elif isinstance(descriptor, ErrorDescriptor): - msg = "ERROR error descriptor loaded: " + msg = "ERROR error descriptor loaded: " msg = msg + descriptor.definition['data']['error_msg'] all_ok = False num_bad += 1 @@ -286,7 +287,7 @@ class TestCoursesLoadTestCase(PageLoader): # xmodule.modulestore.django.modulestore().collection.drop() # store = xmodule.modulestore.django.modulestore() # is there a way to empty the store? - + def test_toy_course_loads(self): self.check_pages_load('toy', TEST_DATA_DIR, modulestore()) @@ -453,6 +454,9 @@ class TestViewAuth(PageLoader): """Check that enrollment periods work""" self.run_wrapped(self._do_test_enrollment_period) + def test_beta_period(self): + """Check that beta-test access works""" + self.run_wrapped(self._do_test_beta_period) def _do_test_dark_launch(self): """Actually do the test, relying on settings to be right.""" @@ -618,6 +622,38 @@ class TestViewAuth(PageLoader): self.unenroll(self.toy) self.assertTrue(self.try_enroll(self.toy)) + def _do_test_beta_period(self): + """Actually test beta periods, relying on settings to be right.""" + + # trust, but verify :) + self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) + + # Make courses start in the future + tomorrow = time.time() + 24 * 3600 + nextday = tomorrow + 24 * 3600 + yesterday = time.time() - 24 * 3600 + + # toy course's hasn't started + self.toy.metadata['start'] = stringify_time(time.gmtime(tomorrow)) + self.assertFalse(self.toy.has_started()) + + # but should be accessible for beta testers + self.toy.metadata['days_early_for_beta'] = '2' + + # student user shouldn't see it + student_user = user(self.student) + self.assertFalse(has_access(student_user, self.toy, 'load')) + + # now add the student to the beta test group + group_name = course_beta_test_group_name(self.toy.location) + g = Group.objects.create(name=group_name) + g.user_set.add(student_user) + + # now the student should see it + self.assertTrue(has_access(student_user, self.toy, 'load')) + + + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestCourseGrader(PageLoader): """Check that a course gets graded properly""" diff --git a/lms/djangoapps/instructor/tests.py b/lms/djangoapps/instructor/tests.py index 2d17cee47d..57c0436921 100644 --- a/lms/djangoapps/instructor/tests.py +++ b/lms/djangoapps/instructor/tests.py @@ -179,7 +179,7 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader): self.assertTrue(response.content.find('Removed "{0}" from "{1}" forum role = "{2}"'.format(username, course.id, rolename))>=0) self.assertFalse(has_forum_access(username, course.id, rolename)) - def test_add_and_readd_forum_admin_users(self): + def test_add_and_read_forum_admin_users(self): course = self.toy self.initialize_roles(course.id) url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 2d58799efe..ddb31bf871 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -2,11 +2,13 @@ from collections import defaultdict import csv +import itertools import json import logging import os import requests import urllib +import json from StringIO import StringIO @@ -19,12 +21,17 @@ from mitxmako.shortcuts import render_to_response from django.core.urlresolvers import reverse from courseware import grades -from courseware.access import has_access, get_access_group_name -from courseware.courses import get_course_with_access -from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA +from courseware.access import (has_access, get_access_group_name, + course_beta_test_group_name) +from courseware.courses import get_course_with_access +from django_comment_client.models import (Role, + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA) from django_comment_client.utils import has_forum_access from psychometrics import psychoanalyze from student.models import CourseEnrollment, CourseEnrollmentAllowed +from courseware.models import StudentModule from xmodule.course_module import CourseDescriptor from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -44,13 +51,12 @@ FORUM_ROLE_REMOVE = 'remove' @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) - def instructor_dashboard(request, course_id): """Display the instructor dashboard for a course.""" course = get_course_with_access(request.user, course_id, 'staff') instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists - + forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR) msg = '' @@ -105,6 +111,16 @@ def instructor_dashboard(request, course_id): except Group.DoesNotExist: group = Group(name=grpname) # create the group group.save() + + def get_beta_group(course): + """ + Get the group for beta testers of course. + """ + # Not using get_group because there is no access control action called + # 'beta', so adding it to get_access_group_name doesn't really make + # sense. + name = course_beta_test_group_name(course.location) + (group, created) = Group.objects.get_or_create(name=name) return group # process actions from form POST @@ -172,6 +188,80 @@ def instructor_dashboard(request, course_id): track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard') return return_csv('answer_dist_{0}.csv'.format(course_id), get_answers_distribution(request, course_id)) + elif "Reset student's attempts" in action: + # get the form data + unique_student_identifier=request.POST.get('unique_student_identifier','') + problem_to_reset=request.POST.get('problem_to_reset','') + + if problem_to_reset[-4:]==".xml": + problem_to_reset=problem_to_reset[:-4] + + # try to uniquely id student by email address or username + try: + if "@" in unique_student_identifier: + student_to_reset=User.objects.get(email=unique_student_identifier) + else: + student_to_reset=User.objects.get(username=unique_student_identifier) + msg+="Found a single student to reset. " + except: + student_to_reset=None + msg+="Couldn't find student with that email or username. " + + if student_to_reset is not None: + # find the module in question + try: + (org, course_name, run)=course_id.split("/") + module_state_key="i4x://"+org+"/"+course_name+"/problem/"+problem_to_reset + module_to_reset=StudentModule.objects.get(student_id=student_to_reset.id, + course_id=course_id, + module_state_key=module_state_key) + msg+="Found module to reset. " + except Exception as e: + msg+="Couldn't find module with that urlname. " + + # modify the problem's state + try: + # load the state json + problem_state=json.loads(module_to_reset.state) + old_number_of_attempts=problem_state["attempts"] + problem_state["attempts"]=0 + + # save + module_to_reset.state=json.dumps(problem_state) + module_to_reset.save() + track.views.server_track(request, + '{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format( + old_attempts=old_number_of_attempts, + student=student_to_reset, + problem=module_to_reset.module_state_key, + instructor=request.user, + course=course_id), + {}, + page='idashboard') + msg+="Module state successfully reset!" + except: + msg+="Couldn't reset module state. " + + + elif "Get link to student's progress page" in action: + unique_student_identifier=request.POST.get('unique_student_identifier','') + try: + if "@" in unique_student_identifier: + student_to_reset=User.objects.get(email=unique_student_identifier) + else: + student_to_reset=User.objects.get(username=unique_student_identifier) + progress_url=reverse('student_progress',kwargs={'course_id':course_id,'student_id': student_to_reset.id}) + track.views.server_track(request, + '{instructor} requested progress page for {student} in {course}'.format( + student=student_to_reset, + instructor=request.user, + course=course_id), + {}, + page='idashboard') + msg+=" Progress page for username: {1} with email address: {2}.".format(progress_url,student_to_reset.username,student_to_reset.email) + except: + msg+="Couldn't find student with that username. " + #---------------------------------------- # export grades to remote gradebook @@ -237,11 +327,7 @@ def instructor_dashboard(request, course_id): elif 'List course staff' in action: group = get_staff_group(course) msg += 'Staff group = {0}'.format(group.name) - log.debug('staffgrp={0}'.format(group.name)) - uset = group.user_set.all() - datatable = {'header': ['Username', 'Full name']} - datatable['data'] = [[x.username, x.profile.name] for x in uset] - datatable['title'] = 'List of Staff in course {0}'.format(course_id) + datatable = _group_members_table(group, "List of Staff", course_id) track.views.server_track(request, 'list-staff', {}, page='idashboard') elif 'List course instructors' in action and request.user.is_staff: @@ -256,17 +342,8 @@ def instructor_dashboard(request, course_id): elif action == 'Add course staff': uname = request.POST['staffuser'] - try: - user = User.objects.get(username=uname) - except User.DoesNotExist: - msg += 'Error: unknown username "{0}"'.format(uname) - user = None - if user is not None: - group = get_staff_group(course) - msg += 'Added {0} to staff group = {1}'.format(user, group.name) - log.debug('staffgrp={0}'.format(group.name)) - user.groups.add(group) - track.views.server_track(request, 'add-staff {0}'.format(user), {}, page='idashboard') + group = get_staff_group(course) + msg += add_user_to_group(request, uname, group, 'staff', 'staff') elif action == 'Add instructor' and request.user.is_staff: uname = request.POST['instructor'] @@ -284,17 +361,8 @@ def instructor_dashboard(request, course_id): elif action == 'Remove course staff': uname = request.POST['staffuser'] - try: - user = User.objects.get(username=uname) - except User.DoesNotExist: - msg += 'Error: unknown username "{0}"'.format(uname) - user = None - if user is not None: - group = get_staff_group(course) - msg += 'Removed {0} from staff group = {1}'.format(user, group.name) - log.debug('staffgrp={0}'.format(group.name)) - user.groups.remove(group) - track.views.server_track(request, 'remove-staff {0}'.format(user), {}, page='idashboard') + group = get_staff_group(course) + msg += remove_user_from_group(request, uname, group, 'staff', 'staff') elif action == 'Remove instructor' and request.user.is_staff: uname = request.POST['instructor'] @@ -310,26 +378,50 @@ def instructor_dashboard(request, course_id): user.groups.remove(group) track.views.server_track(request, 'remove-instructor {0}'.format(user), {}, page='idashboard') + #---------------------------------------- + # Group management + + elif 'List beta testers' in action: + group = get_beta_group(course) + msg += 'Beta test group = {0}'.format(group.name) + datatable = _group_members_table(group, "List of beta_testers", course_id) + track.views.server_track(request, 'list-beta-testers', {}, page='idashboard') + + elif action == 'Add beta testers': + users = request.POST['betausers'] + log.debug("users: {0!r}".format(users)) + group = get_beta_group(course) + for username_or_email in _split_by_comma_and_whitespace(users): + msg += "

{0}

".format( + add_user_to_group(request, username_or_email, group, 'beta testers', 'beta-tester')) + + elif action == 'Remove beta testers': + users = request.POST['betausers'] + group = get_beta_group(course) + for username_or_email in _split_by_comma_and_whitespace(users): + msg += "

{0}

".format( + remove_user_from_group(request, username_or_email, group, 'beta testers', 'beta-tester')) + #---------------------------------------- # forum administration - + elif action == 'List course forum admins': rolename = FORUM_ROLE_ADMINISTRATOR datatable = {} msg += _list_course_forum_members(course_id, rolename, datatable) track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') - - + + elif action == 'Remove forum admin': uname = request.POST['forumadmin'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_REMOVE) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_ADMINISTRATOR, course_id), + track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_ADMINISTRATOR, course_id), {}, page='idashboard') elif action == 'Add forum admin': uname = request.POST['forumadmin'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_ADD) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_ADMINISTRATOR, course_id), + track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_ADMINISTRATOR, course_id), {}, page='idashboard') elif action == 'List course forum moderators': @@ -337,35 +429,35 @@ def instructor_dashboard(request, course_id): datatable = {} msg += _list_course_forum_members(course_id, rolename, datatable) track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') - + elif action == 'Remove forum moderator': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_REMOVE) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_MODERATOR, course_id), + track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_MODERATOR, course_id), {}, page='idashboard') - + elif action == 'Add forum moderator': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_ADD) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_MODERATOR, course_id), + track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_MODERATOR, course_id), {}, page='idashboard') - + elif action == 'List course forum community TAs': rolename = FORUM_ROLE_COMMUNITY_TA datatable = {} msg += _list_course_forum_members(course_id, rolename, datatable) track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') - + elif action == 'Remove forum community TA': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_REMOVE) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_COMMUNITY_TA, course_id), + track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_COMMUNITY_TA, course_id), {}, page='idashboard') - + elif action == 'Add forum community TA': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_ADD) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_COMMUNITY_TA, course_id), + track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_COMMUNITY_TA, course_id), {}, page='idashboard') #---------------------------------------- @@ -418,7 +510,7 @@ def instructor_dashboard(request, course_id): msg2, datatable = _do_remote_gradebook(request.user, course, 'get-sections') msg += msg2 - elif action in ['List students in section in remote gradebook', + elif action in ['List students in section in remote gradebook', 'Overload enrollment list using remote gradebook', 'Merge enrollment list with remote gradebook']: @@ -431,7 +523,7 @@ def instructor_dashboard(request, course_id): overload = 'Overload' in action ret = _do_enroll_students(course, course_id, students, overload=overload) datatable = ret['datatable'] - + #---------------------------------------- # psychometrics @@ -448,7 +540,7 @@ def instructor_dashboard(request, course_id): #---------------------------------------- # offline grades? - + if use_offline: msg += "
Grades from %s" % offline_grades_available(course_id) @@ -482,17 +574,17 @@ def _do_remote_gradebook(user, course, action, args=None, files=None): if not rg: msg = "No remote gradebook defined in course metadata" return msg, {} - + rgurl = settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','') if not rgurl: msg = "No remote gradebook url defined in settings.MITX_FEATURES" return msg, {} - + rgname = rg.get('name','') if not rgname: msg = "No gradebook name defined in course remote_gradebook metadata" return msg, {} - + if args is None: args = {} data = dict(submit=action, gradebook=rgname, user=user.email) @@ -522,15 +614,15 @@ def _do_remote_gradebook(user, course, action, args=None, files=None): return msg, datatable def _list_course_forum_members(course_id, rolename, datatable): - ''' + """ Fills in datatable with forum membership information, for a given role, so that it will be displayed on instructor dashboard. - + course_ID = the ID string for a course rolename = one of "Administrator", "Moderator", "Community TA" - + Returns message status string to append to displayed message, if role is unknown. - ''' + """ # make sure datatable is set up properly for display first, before checking for errors datatable['header'] = ['Username', 'Full name', 'Roles'] datatable['title'] = 'List of Forum {0}s in course {1}'.format(rolename, course_id) @@ -549,13 +641,13 @@ def _list_course_forum_members(course_id, rolename, datatable): def _update_forum_role_membership(uname, course, rolename, add_or_remove): ''' Supports adding a user to a course's forum role - + uname = username string for user - course = course object + course = course object rolename = one of "Administrator", "Moderator", "Community TA" add_or_remove = one of "add" or "remove" - - Returns message status string to append to displayed message, Status is returned if user + + Returns message status string to append to displayed message, Status is returned if user or role is unknown, or if entry already exists when adding, or if entry doesn't exist when removing. ''' # check that username and rolename are valid: @@ -575,21 +667,105 @@ def _update_forum_role_membership(uname, course, rolename, add_or_remove): if add_or_remove == FORUM_ROLE_REMOVE: if not alreadyexists: msg ='Error: user "{0}" does not have rolename "{1}", cannot remove'.format(uname, rolename) - else: + else: user.roles.remove(role) msg = 'Removed "{0}" from "{1}" forum role = "{2}"'.format(user, course.id, rolename) else: if alreadyexists: msg = 'Error: user "{0}" already has rolename "{1}", cannot add'.format(uname, rolename) - else: - if (rolename == FORUM_ROLE_ADMINISTRATOR and not has_access(user, course, 'staff')): + else: + if (rolename == FORUM_ROLE_ADMINISTRATOR and not has_access(user, course, 'staff')): msg = 'Error: user "{0}" should first be added as staff before adding as a forum administrator, cannot add'.format(uname) else: user.roles.add(role) msg = 'Added "{0}" to "{1}" forum role = "{2}"'.format(user, course.id, rolename) return msg - + +def _group_members_table(group, title, course_id): + """ + Return a data table of usernames and names of users in group_name. + + Arguments: + group -- a django group. + title -- a descriptive title to show the user + + Returns: + a dictionary with keys + 'header': ['Username', 'Full name'], + 'data': [[username, name] for all users] + 'title': "{title} in course {course}" + """ + uset = group.user_set.all() + datatable = {'header': ['Username', 'Full name']} + datatable['data'] = [[x.username, x.profile.name] for x in uset] + datatable['title'] = '{0} in course {1}'.format(title, course_id) + return datatable + + +def _add_or_remove_user_group(request, username_or_email, group, group_title, event_name, do_add): + """ + Implementation for both add and remove functions, to get rid of shared code. do_add is bool that determines which + to do. + """ + user = None + try: + if '@' in username_or_email: + user = User.objects.get(email=username_or_email) + else: + user = User.objects.get(username=username_or_email) + except User.DoesNotExist: + msg = 'Error: unknown username or email "{0}"'.format(username_or_email) + user = None + + if user is not None: + action = "Added" if do_add else "Removed" + prep = "to" if do_add else "from" + msg = '{action} {0} {prep} {1} group = {2}'.format(user, group_title, group.name, + action=action, prep=prep) + if do_add: + user.groups.add(group) + else: + user.groups.remove(group) + event = "add" if do_add else "remove" + track.views.server_track(request, '{event}-{0} {1}'.format(event_name, user, event=event), + {}, page='idashboard') + + return msg + + +def add_user_to_group(request, username_or_email, group, group_title, event_name): + """ + Look up the given user by username (if no '@') or email (otherwise), and add them to group. + + Arguments: + request: django request--used for tracking log + username_or_email: who to add. Decide if it's an email by presense of an '@' + group: django group object + group_title: what to call this group in messages to user--e.g. "beta-testers". + event_name: what to call this event when logging to tracking logs. + + Returns: + html to insert in the message field + """ + return _add_or_remove_user_group(request, username_or_email, group, group_title, event_name, True) + +def remove_user_from_group(request, username_or_email, group, group_title, event_name): + """ + Look up the given user by username (if no '@') or email (otherwise), and remove them from group. + + Arguments: + request: django request--used for tracking log + username_or_email: who to remove. Decide if it's an email by presense of an '@' + group: django group object + group_title: what to call this group in messages to user--e.g. "beta-testers". + event_name: what to call this event when logging to tracking logs. + + Returns: + html to insert in the message field + """ + return _add_or_remove_user_group(request, username_or_email, group, group_title, event_name, False) + def get_student_grade_summary_data(request, course, course_id, get_grades=True, get_raw_scores=False, use_offline=False): ''' @@ -694,12 +870,20 @@ def grade_summary(request, course_id): # enrollment +def _split_by_comma_and_whitespace(s): + """ + Split a string both by on commas and whitespice. + """ + # Note: split() with no args removes empty strings from output + lists = [x.split() for x in s.split(',')] + # return all of them + return itertools.chain(*lists) + def _do_enroll_students(course, course_id, students, overload=False): """Do the actual work of enrolling multiple students, presented as a string of emails separated by commas or returns""" - ns = [x.split('\n') for x in students.split(',')] - new_students = [item for sublist in ns for item in sublist] + new_students = _split_by_comma_and_whitespace(students) new_students = [str(s.strip()) for s in new_students] new_students_lc = [x.lower() for x in new_students] @@ -750,7 +934,7 @@ def _do_enroll_students(course, course_id, students, overload=False): def sf(stat): return [x for x in status if status[x]==stat] - data = dict(added=sf('added'), rejected=sf('rejected')+sf('exists'), + data = dict(added=sf('added'), rejected=sf('rejected')+sf('exists'), deleted=sf('deleted'), datatable=datatable) return data 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/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 7f1912cd45..4d46505705 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -36,6 +36,9 @@ table.stat_table td { a.selectedmode { background-color: yellow; } +textarea { + height: 200px; +} + + +
+ +
+
+
+

${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