diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 2bb9d98be7..d01e784d74 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -12,7 +12,7 @@ import re import logging -class CourseDetails: +class CourseDetails(object): def __init__(self, location): self.course_location = location # a Location obj self.start_date = None # 'start' @@ -79,8 +79,7 @@ class CourseDetails: descriptor = get_modulestore(course_location).get_item(course_location) dirty = False - - ## ??? Will this comparison work? + if 'start_date' in jsondict: converted = jsdate_to_time(jsondict['start_date']) else: diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index e0bab1f225..9cfa18c8c9 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -4,7 +4,7 @@ import re from util import converters -class CourseGradingModel: +class CourseGradingModel(object): """ Basically a DAO and Model combo for CRUD operations pertaining to grading policy. """ diff --git a/cms/static/css/tiny-mce.css b/cms/static/css/tiny-mce.css index eff44c917f..c142a51f95 100644 --- a/cms/static/css/tiny-mce.css +++ b/cms/static/css/tiny-mce.css @@ -1,129 +1,130 @@ @font-face{font-family:'Open Sans';font-style:normal;font-weight:700;src:local("Open Sans Bold"),local("OpenSans-Bold"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/k3k702ZOKiLJc3WVjuplzKRDOzjiPcYnFooOUGCOsRk.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:normal;font-weight:300;src:local("Open Sans Light"),local("OpenSans-Light"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/DXI1ORHCpsQm3Vp6mXoaTaRDOzjiPcYnFooOUGCOsRk.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:italic;font-weight:700;src:local("Open Sans Bold Italic"),local("OpenSans-BoldItalic"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/PRmiXeptR36kaC0GEAetxhbnBKKEOwRKgsHDreGcocg.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:italic;font-weight:300;src:local("Open Sans Light Italic"),local("OpenSansLight-Italic"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/PRmiXeptR36kaC0GEAetxvR_54zmj3SbGZQh3vCOwvY.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:italic;font-weight:400;src:local("Open Sans Italic"),local("OpenSans-Italic"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/xjAJXh38I15wypJXxuGMBrrIa-7acMAeDBVuclsi6Gc.woff) format("woff")}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;src:local("Open Sans"),local("OpenSans"),url(http://themes.googleusercontent.com/static/fonts/opensans/v6/cJZKeOuBrn4kERxqtaUH3bO3LdcAZYWl9Si6vvxL-qU.woff) format("woff")} .mceContentBody { - padding: 10px; - background-color: #fff; - font-family: 'Open Sans', Verdana, Arial, Helvetica, sans-serif; - font-size: 16px; - line-height: 1.6; - color: #3c3c3c; - scrollbar-3dlight-color: #F0F0EE; - scrollbar-arrow-color: #676662; - scrollbar-base-color: #F0F0EE; - scrollbar-darkshadow-color: #DDDDDD; - scrollbar-face-color: #E0E0DD; - scrollbar-highlight-color: #F0F0EE; - scrollbar-shadow-color: #F0F0EE; - scrollbar-track-color: #F5F5F5; + padding: 10px; + background-color: #fff; + font-family: 'Open Sans', Verdana, Arial, Helvetica, sans-serif; + font-size: 16px; + line-height: 1.6; + color: #3c3c3c; + scrollbar-3dlight-color: #F0F0EE; + scrollbar-arrow-color: #676662; + scrollbar-base-color: #F0F0EE; + scrollbar-darkshadow-color: #DDDDDD; + scrollbar-face-color: #E0E0DD; + scrollbar-highlight-color: #F0F0EE; + scrollbar-shadow-color: #F0F0EE; + scrollbar-track-color: #F5F5F5; } h1 { - color: #3c3c3c; - font-weight: normal; - font-size: 2em; - line-height: 1.4em; - letter-spacing: 1px; - margin: 0 0 1.416em 0; + color: #3c3c3c; + font-weight: normal; + font-size: 2em; + line-height: 1.4em; + letter-spacing: 1px; + margin: 0 0 1.416em 0; } h2 { - color: #646464; - font-weight: normal; - font-size: 1.2em; - line-height: 1.2em; - letter-spacing: 1px; - margin-bottom: 15px; - text-transform: uppercase; - -webkit-font-smoothing: antialiased; + color: #646464; + font-weight: normal; + font-size: 1.2em; + line-height: 1.2em; + letter-spacing: 1px; + margin-bottom: 15px; + text-transform: uppercase; + -webkit-font-smoothing: antialiased; } h3, h4, h5, h6 { - margin: 0 0 10px 0; - font-weight: 600; + margin: 0 0 10px 0; + font-weight: 600; } h3 { - font-size: 1.2em; + font-size: 1.2em; } h4 { - font-size: 1em; + font-size: 1em; } h5 { - font-size: .83em; + font-size: .83em; } h6 { - font-size: 0.75em; + font-size: 0.75em; } p { - margin-bottom: 1.416em; - font-size: 1em; - line-height: 1.6em !important; - color: #3c3c3c; + margin-bottom: 1.416em; + font-size: 1em; + line-height: 1.6em !important; + color: #3c3c3c; } em, i { - font-style: italic; + font-style: italic; } strong, b { - font-style: bold; + font-style: bold; } p + p, ul + p, ol + p { - margin-top: 20px; + margin-top: 20px; } ol, ul { - margin: 1em 0; - padding: 0 0 0 1em; - color: #3c3c3c; + margin: 1em 0; + padding: 0 0 0 1em; + color: #3c3c3c; + } ol li, ul li { - margin-bottom: 0.708em; + margin-bottom: 0.708em; } ol { - list-style: decimal outside none; + list-style: decimal outside none; } ul { - list-style: disc outside none; + list-style: disc outside none; } a, a:link, a:visited, a:hover, a:active { - color: #1d9dd9; -} + color: #1d9dd9; +} img { - max-width: 100%; + max-width: 100%; } code { - font-family: monospace, serif; - background: none; - color: #3c3c3c; + font-family: monospace, serif; + background: none; + color: #3c3c3c; } table { - width: 100%; - border-collapse: collapse; - font-size: 16px; + width: 100%; + border-collapse: collapse; + font-size: 16px; } -th { - background: #eee; - font-weight: bold; +th { + background: #eee; + font-weight: bold; } -table td, th { - margin: 20px 0; - padding: 10px; - border: 1px solid #ccc !important; - text-align: left; - font-size: 14px; -} \ No newline at end of file +table td, th { + margin: 20px 0; + padding: 10px; + border: 1px solid #ccc !important; + text-align: left; + font-size: 14px; +} diff --git a/cms/static/sass/_unit.scss b/cms/static/sass/_unit.scss index 5e1a15077c..d8ca1117e9 100644 --- a/cms/static/sass/_unit.scss +++ b/cms/static/sass/_unit.scss @@ -51,6 +51,7 @@ } .components { + > li { position: relative; z-index: 10; @@ -118,6 +119,24 @@ } } + .new-component-templates { + display: none; + padding: 20px; + @include clearfix; + + .cancel-button { + @include white-button; + } + + // specific menu types + &.new-component-problem { + + .ss-icon, .editor-indicator { + display: inline-block; + } + } + } + .new-component-type, .new-component-template { @include clearfix; @@ -177,7 +196,11 @@ position: relative; top: 3px; font-size: 12px; - opacity: 0.1; + opacity: 0.3; + } + + .ss-icon, .editor-indicator { + display: none; } &:hover { @@ -214,16 +237,6 @@ } } - - .new-component-templates { - display: none; - padding: 20px; - @include clearfix; - - .cancel-button { - @include white-button; - } - } } } } @@ -553,4 +566,4 @@ body.unit { padding-top: 0; } } -} \ No newline at end of file +} diff --git a/common/djangoapps/models/course_relative.py b/common/djangoapps/models/course_relative.py index 4dfb83d183..58cc0fb0de 100644 --- a/common/djangoapps/models/course_relative.py +++ b/common/djangoapps/models/course_relative.py @@ -1,4 +1,4 @@ -class CourseRelativeMember: +class CourseRelativeMember(object): def __init__(self, location, idx): self.course_location = location # a Location obj self.idx = idx # which milestone this represents. Hopefully persisted # so we don't have race conditions diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index ec3b708ca7..64fe844801 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -12,6 +12,8 @@ admin.site.register(UserTestGroup) admin.site.register(CourseEnrollment) +admin.site.register(CourseEnrollmentAllowed) + admin.site.register(Registration) admin.site.register(PendingNameChange) 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/0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py b/common/djangoapps/student/migrations/0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py new file mode 100644 index 0000000000..f7e2571685 --- /dev/null +++ b/common/djangoapps/student/migrations/0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CourseEnrollmentAllowed' + db.create_table('student_courseenrollmentallowed', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('email', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)), + )) + db.send_create_signal('student', ['CourseEnrollmentAllowed']) + + # Adding unique constraint on 'CourseEnrollmentAllowed', fields ['email', 'course_id'] + db.create_unique('student_courseenrollmentallowed', ['email', 'course_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'CourseEnrollmentAllowed', fields ['email', 'course_id'] + db.delete_unique('student_courseenrollmentallowed', ['email', 'course_id']) + + # Deleting model 'CourseEnrollmentAllowed' + db.delete_table('student_courseenrollmentallowed') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.courseenrollment': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file 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 4932e579a7..f13a691215 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,6 +49,7 @@ 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 @@ -125,6 +128,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 +146,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 +159,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 +191,371 @@ 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.upload_status = '' + new_user.save() + log.info("Updated demographic information for user's test center exam registration: username \"{}\" ".format(new_user.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.upload_status = '' + 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) @@ -261,6 +619,22 @@ class CourseEnrollment(models.Model): return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created) +class CourseEnrollmentAllowed(models.Model): + """ + Table of users (specified by email address strings) who are allowed to enroll in a specified course. + The user may or may not (yet) exist. Enrollment by users listed in this table is allowed + even if the enrollment time window is past. + """ + email = models.CharField(max_length=255, db_index=True) + course_id = models.CharField(max_length=255, db_index=True) + + created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) + + class Meta: + unique_together = (('email', 'course_id'), ) + + def __unicode__(self): + return "[CourseEnrollmentAllowed] %s: %s (%s)" % (self.email, self.course_id, self.created) #cache_relation(User.profile) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 39805fd85f..61b49e6022 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,21 +27,22 @@ 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 +from courseware.courses import get_courses, sort_by_announcement from courseware.access import has_access from statsd import statsd @@ -76,10 +78,7 @@ def index(request, extra_context={}, user=None): domain = request.META.get('HTTP_HOST') courses = get_courses(None, domain=domain) - - # Sort courses by how far are they from they start day - key = lambda course: course.days_until_start - courses = sorted(courses, key=key, reverse=True) + courses = sort_by_announcement(courses) # Get the 3 most recent news top_news = _get_news(top=3) @@ -239,6 +238,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 +250,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 +302,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 +468,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 +602,172 @@ 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: + log.error("User {0} enrolled in non-existent course {1}".format(user.username, course_id)) + raise Http404 + + # get the exam to be registered for: + # (For now, we just assume there is one at most.) + # if there is no exam now (because someone bookmarked this stupid page), + # then return a 404: + exam_info = course.current_test_center_exam + if exam_info is None: + raise Http404 + + # 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.... + + # make sure that any demographic data values received from the page have been stripped. + # Whitespace is not an acceptable response for any of these values + demographic_data = {} + for fieldname in TestCenterUser.user_provided_fields(): + if fieldname in post_vars: + demographic_data[fieldname] = (post_vars[fieldname]).strip() + + try: + testcenter_user = TestCenterUser.objects.get(user=user) + needs_updating = testcenter_user.needs_update(demographic_data) + 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=demographic_data) + 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 +823,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/djangoapps/util/converters.py b/common/djangoapps/util/converters.py index 17c45114d1..7f96dc6c30 100644 --- a/common/djangoapps/util/converters.py +++ b/common/djangoapps/util/converters.py @@ -15,7 +15,7 @@ def jsdate_to_time(field): """ if field is None: return field - elif isinstance(field, unicode) or isinstance(field, str): # iso format but ignores time zone assuming it's Z + elif isinstance(field, basestring): # iso format but ignores time zone assuming it's Z d=datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable return d.utctimetuple() elif isinstance(field, int) or isinstance(field, float): diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index efc96fc717..4b0faa91a1 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -34,6 +34,8 @@ import chem import chem.chemcalc import chem.chemtools import chem.miller +import verifiers +import verifiers.draganddrop import calc from correctmap import CorrectMap @@ -69,7 +71,8 @@ global_context = {'random': random, 'eia': eia, 'chemcalc': chem.chemcalc, 'chemtools': chem.chemtools, - 'miller': chem.miller} + 'miller': chem.miller, + 'draganddrop': verifiers.draganddrop} # These should be removed from HTML output, including all subelements html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam","openendedrubric"] @@ -186,24 +189,6 @@ class LoncapaProblem(object): maxscore += responder.get_max_score() return maxscore - def message_post(self,event_info): - """ - Handle an ajax post that contains feedback on feedback - Returns a boolean success variable - Note: This only allows for feedback to be posted back to the grading controller for the first - open ended response problem on each page. Multiple problems will cause some sync issues. - TODO: Handle multiple problems on one page sync issues. - """ - success=False - message = "Could not find a valid responder." - log.debug("in lcp") - for responder in self.responders.values(): - if hasattr(responder, 'handle_message_post'): - success, message = responder.handle_message_post(event_info) - if success: - break - return success, message - def get_score(self): """ Compute score for this problem. The score is the number of points awarded. diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index e3eb47acc5..0b0e86ce66 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -13,6 +13,9 @@ Module containing the problem elements which render into input objects - imageinput (for clickable image) - optioninput (for option list) - filesubmission (upload a file) +- crystallography +- vsepr_input +- drag_and_drop These are matched by *.html files templates/*.html which are mako templates with the actual html. @@ -41,6 +44,7 @@ from lxml import etree import re import shlex # for splitting quoted strings import sys +import os from registry import TagRegistry @@ -692,7 +696,7 @@ class VseprInput(InputTypeBase): @classmethod def get_attributes(cls): """ - Note: height, width are required. + Note: height, width, molecules and geometries are required. """ return [Attribute('height'), Attribute('width'), @@ -736,50 +740,92 @@ registry.register(ChemicalEquationInput) #----------------------------------------------------------------------------- -class OpenEndedInput(InputTypeBase): +class DragAndDropInput(InputTypeBase): """ - A text area input for code--uses codemirror, does syntax highlighting, special tab handling, - etc. + Input for drag and drop problems. Allows student to drag and drop images and + labels to base image. """ - template = "openendedinput.html" - tags = ['openendedinput'] - - # pulled out for testing - submitted_msg = ("Feedback not yet available. Reload to check again. " - "Once the problem is graded, this message will be " - "replaced with the grader's feedback.") - - @classmethod - def get_attributes(cls): - """ - Convert options to a convenient format. - """ - return [Attribute('rows', '30'), - Attribute('cols', '80'), - Attribute('hidden', ''), - ] + template = 'drag_and_drop_input.html' + tags = ['drag_and_drop_input'] def setup(self): - """ - Implement special logic: handle queueing state, and default input. - """ - # if no student input yet, then use the default input given by the problem - if not self.value: - self.value = self.xml.text - # Check if problem has been queued - self.queue_len = 0 - # Flag indicating that the problem has been queued, 'msg' is length of queue - if self.status == 'incomplete': - self.status = 'queued' - self.queue_len = self.msg - self.msg = self.submitted_msg + def parse(tag, tag_type): + """Parses xml element to dictionary. Stores + 'draggable' and 'target' tags with attributes to dictionary and + returns last. - def _extra_context(self): - """Defined queue_len, add it """ - return {'queue_len': self.queue_len,} + Args: + tag: xml etree element with attributes -registry.register(OpenEndedInput) + tag_type: 'draggable' or 'target'. -#----------------------------------------------------------------------------- + If tag_type is 'draggable' : all attributes except id + (name or label or icon or can_reuse) are optional + + If tag_type is 'target' all attributes (name, x, y, w, h) + are required. (x, y) - coordinates of center of target, + w, h - weight and height of target. + + Returns: + Dictionary of vaues of attributes: + dict{'name': smth, 'label': smth, 'icon': smth, + 'can_reuse': smth}. + """ + tag_attrs = dict() + tag_attrs['draggable'] = {'id': Attribute._sentinel, + 'label': "", 'icon': "", + 'can_reuse': ""} + + tag_attrs['target'] = {'id': Attribute._sentinel, + 'x': Attribute._sentinel, + 'y': Attribute._sentinel, + 'w': Attribute._sentinel, + 'h': Attribute._sentinel} + + dic = dict() + + for attr_name in tag_attrs[tag_type].keys(): + dic[attr_name] = Attribute(attr_name, + default=tag_attrs[tag_type][attr_name]).parse_from_xml(tag) + + if tag_type == 'draggable' and not self.no_labels: + dic['label'] = dic['label'] or dic['id'] + + return dic + + # add labels to images?: + self.no_labels = Attribute('no_labels', + default="False").parse_from_xml(self.xml) + + to_js = dict() + + # image drag and drop onto + to_js['base_image'] = Attribute('img').parse_from_xml(self.xml) + + # outline places on image where to drag adn drop + to_js['target_outline'] = Attribute('target_outline', + default="False").parse_from_xml(self.xml) + # one draggable per target? + to_js['one_per_target'] = Attribute('one_per_target', + default="True").parse_from_xml(self.xml) + # list of draggables + to_js['draggables'] = [parse(draggable, 'draggable') for draggable in + self.xml.iterchildren('draggable')] + # list of targets + to_js['targets'] = [parse(target, 'target') for target in + self.xml.iterchildren('target')] + + # custom background color for labels: + label_bg_color = Attribute('label_bg_color', + default=None).parse_from_xml(self.xml) + if label_bg_color: + to_js['label_bg_color'] = label_bg_color + + self.loaded_attributes['drag_and_drop_json'] = json.dumps(to_js) + self.to_render.add('drag_and_drop_json') + +registry.register(DragAndDropInput) + +#-------------------------------------------------------------------------------------------------------------------- diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 9242e638c6..5f0e1639b2 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -33,7 +33,7 @@ from correctmap import CorrectMap from datetime import datetime from util import * from lxml import etree -from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? +from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? import xqueue_interface log = logging.getLogger('mitx.' + __name__) @@ -873,7 +873,9 @@ def sympy_check2(): response_tag = 'customresponse' - allowed_inputfields = ['textline', 'textbox', 'crystallography', 'chemicalequationinput', 'vsepr_input'] + allowed_inputfields = ['textline', 'textbox', 'crystallography', + 'chemicalequationinput', 'vsepr_input', + 'drag_and_drop_input'] def setup_response(self): xml = self.xml @@ -1048,7 +1050,7 @@ def sympy_check2(): pretty_print=True) #msg = etree.tostring(fromstring_bs(msg),pretty_print=True) msg = msg.replace(' ', '') - #msg = re.sub('(.*)','\\1',msg,flags=re.M|re.DOTALL) # python 2.7 + #msg = re.sub('(.*)','\\1',msg,flags=re.M|re.DOTALL) # python 2.7 msg = re.sub('(?ms)(.*)', '\\1', msg) messages[0] = msg @@ -1780,7 +1782,7 @@ class ImageResponse(LoncapaResponse): def get_score(self, student_answers): correct_map = CorrectMap() expectedset = self.get_answers() - for aid in self.answer_ids: # loop through IDs of + for aid in self.answer_ids: # loop through IDs of # fields in our stanza given = student_answers[aid] # this should be a string of the form '[x,y]' correct_map.set(aid, 'incorrect') @@ -1833,443 +1835,6 @@ class ImageResponse(LoncapaResponse): dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements])) #----------------------------------------------------------------------------- -class OpenEndedResponse(LoncapaResponse): - """ - Grade student open ended responses using an external grading system, - accessed through the xqueue system. - - Expects 'xqueue' dict in ModuleSystem with the following keys that are - needed by OpenEndedResponse: - - system.xqueue = { 'interface': XqueueInterface object, - 'callback_url': Per-StudentModule callback URL - where results are posted (string), - } - - External requests are only submitted for student submission grading - (i.e. and not for getting reference answers) - - By default, uses the OpenEndedResponse.DEFAULT_QUEUE queue. - """ - - DEFAULT_QUEUE = 'open-ended' - DEFAULT_MESSAGE_QUEUE = 'open-ended-message' - response_tag = 'openendedresponse' - allowed_inputfields = ['openendedinput'] - max_inputfields = 1 - - def setup_response(self): - ''' - Configure OpenEndedResponse from XML. - ''' - xml = self.xml - self.url = xml.get('url', None) - self.queue_name = xml.get('queuename', self.DEFAULT_QUEUE) - self.message_queue_name = xml.get('message-queuename', self.DEFAULT_MESSAGE_QUEUE) - - # The openendedparam tag encapsulates all grader settings - oeparam = self.xml.find('openendedparam') - prompt = self.xml.find('prompt') - rubric = self.xml.find('openendedrubric') - - #This is needed to attach feedback to specific responses later - self.submission_id=None - self.grader_id=None - - if oeparam is None: - raise ValueError("No oeparam found in problem xml.") - if prompt is None: - raise ValueError("No prompt found in problem xml.") - if rubric is None: - raise ValueError("No rubric found in problem xml.") - - self._parse(oeparam, prompt, rubric) - - @staticmethod - def stringify_children(node): - """ - Modify code from stringify_children in xmodule. Didn't import directly - in order to avoid capa depending on xmodule (seems to be avoided in - code) - """ - parts=[node.text if node.text is not None else ''] - for p in node.getchildren(): - parts.append(etree.tostring(p, with_tail=True, encoding='unicode')) - - return ' '.join(parts) - - def _parse(self, oeparam, prompt, rubric): - ''' - Parse OpenEndedResponse XML: - self.initial_display - self.payload - dict containing keys -- - 'grader' : path to grader settings file, 'problem_id' : id of the problem - - self.answer - What to display when show answer is clicked - ''' - # Note that OpenEndedResponse is agnostic to the specific contents of grader_payload - prompt_string = self.stringify_children(prompt) - rubric_string = self.stringify_children(rubric) - - grader_payload = oeparam.find('grader_payload') - grader_payload = grader_payload.text if grader_payload is not None else '' - - #Update grader payload with student id. If grader payload not json, error. - try: - parsed_grader_payload = json.loads(grader_payload) - # NOTE: self.system.location is valid because the capa_module - # __init__ adds it (easiest way to get problem location into - # response types) - except TypeError, ValueError: - log.exception("Grader payload %r is not a json object!", grader_payload) - - self.initial_display = find_with_default(oeparam, 'initial_display', '') - self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.') - - parsed_grader_payload.update({ - 'location' : self.system.location, - 'course_id' : self.system.course_id, - 'prompt' : prompt_string, - 'rubric' : rubric_string, - 'initial_display' : self.initial_display, - 'answer' : self.answer, - }) - - updated_grader_payload = json.dumps(parsed_grader_payload) - - self.payload = {'grader_payload': updated_grader_payload} - - try: - self.max_score = int(find_with_default(oeparam, 'max_score', 1)) - except ValueError: - self.max_score = 1 - - def handle_message_post(self,event_info): - """ - Handles a student message post (a reaction to the grade they received from an open ended grader type) - Returns a boolean success/fail and an error message - """ - survey_responses=event_info['survey_responses'] - for tag in ['feedback', 'submission_id', 'grader_id', 'score']: - if tag not in survey_responses: - return False, "Could not find needed tag {0}".format(tag) - try: - submission_id=int(survey_responses['submission_id']) - grader_id = int(survey_responses['grader_id']) - feedback = str(survey_responses['feedback'].encode('ascii', 'ignore')) - score = int(survey_responses['score']) - except: - error_message=("Could not parse submission id, grader id, " - "or feedback from message_post ajax call. Here is the message data: {0}".format(survey_responses)) - log.exception(error_message) - return False, "There was an error saving your feedback. Please contact course staff." - - qinterface = self.system.xqueue['interface'] - qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) - anonymous_student_id = self.system.anonymous_student_id - queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + - anonymous_student_id + - self.answer_id) - - xheader = xqueue_interface.make_xheader( - lms_callback_url=self.system.xqueue['callback_url'], - lms_key=queuekey, - queue_name=self.message_queue_name - ) - - student_info = {'anonymous_student_id': anonymous_student_id, - 'submission_time': qtime, - } - contents= { - 'feedback' : feedback, - 'submission_id' : submission_id, - 'grader_id' : grader_id, - 'score': score, - 'student_info' : json.dumps(student_info), - } - - (error, msg) = qinterface.send_to_queue(header=xheader, - body=json.dumps(contents)) - - #Convert error to a success value - success=True - if error: - success=False - - return success, "Successfully submitted your feedback." - - - def get_score(self, student_answers): - - try: - submission = student_answers[self.answer_id] - except KeyError: - msg = ('Cannot get student answer for answer_id: {0}. student_answers {1}' - .format(self.answer_id, student_answers)) - log.exception(msg) - raise LoncapaProblemError(msg) - - # Prepare xqueue request - #------------------------------------------------------------ - - qinterface = self.system.xqueue['interface'] - qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) - - anonymous_student_id = self.system.anonymous_student_id - - # Generate header - queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + - anonymous_student_id + - self.answer_id) - - xheader = xqueue_interface.make_xheader(lms_callback_url=self.system.xqueue['callback_url'], - lms_key=queuekey, - queue_name=self.queue_name) - - self.context.update({'submission': submission}) - - contents = self.payload.copy() - - # Metadata related to the student submission revealed to the external grader - student_info = {'anonymous_student_id': anonymous_student_id, - 'submission_time': qtime, - } - - #Update contents with student response and student info - contents.update({ - 'student_info': json.dumps(student_info), - 'student_response': submission, - 'max_score' : self.max_score, - }) - - # Submit request. When successful, 'msg' is the prior length of the queue - (error, msg) = qinterface.send_to_queue(header=xheader, - body=json.dumps(contents)) - - # State associated with the queueing request - queuestate = {'key': queuekey, - 'time': qtime,} - - cmap = CorrectMap() - if error: - cmap.set(self.answer_id, queuestate=None, - msg='Unable to deliver your submission to grader. (Reason: {0}.)' - ' Please try again later.'.format(msg)) - else: - # Queueing mechanism flags: - # 1) Backend: Non-null CorrectMap['queuestate'] indicates that - # the problem has been queued - # 2) Frontend: correctness='incomplete' eventually trickles down - # through inputtypes.textbox and .filesubmission to inform the - # browser that the submission is queued (and it could e.g. poll) - cmap.set(self.answer_id, queuestate=queuestate, - correctness='incomplete', msg=msg) - - return cmap - - def update_score(self, score_msg, oldcmap, queuekey): - log.debug(score_msg) - score_msg = self._parse_score_msg(score_msg) - if not score_msg.valid: - oldcmap.set(self.answer_id, - msg = 'Invalid grader reply. Please contact the course staff.') - return oldcmap - - correctness = 'correct' if score_msg.correct else 'incorrect' - - # TODO: Find out how this is used elsewhere, if any - self.context['correct'] = correctness - - # Replace 'oldcmap' with new grading results if queuekey matches. If queuekey - # does not match, we keep waiting for the score_msg whose key actually matches - if oldcmap.is_right_queuekey(self.answer_id, queuekey): - # Sanity check on returned points - points = score_msg.points - if points < 0: - points = 0 - - # Queuestate is consumed, so reset it to None - oldcmap.set(self.answer_id, npoints=points, correctness=correctness, - msg = score_msg.msg.replace(' ', ' '), queuestate=None) - else: - log.debug('OpenEndedResponse: queuekey {0} does not match for answer_id={1}.'.format( - queuekey, self.answer_id)) - - return oldcmap - - def get_answers(self): - anshtml = '
{0}
'.format(self.answer) - return {self.answer_id: anshtml} - - def get_initial_display(self): - return {self.answer_id: self.initial_display} - - def _convert_longform_feedback_to_html(self, response_items): - """ - Take in a dictionary, and return html strings for display to student. - Input: - response_items: Dictionary with keys success, feedback. - if success is True, feedback should be a dictionary, with keys for - types of feedback, and the corresponding feedback values. - if success is False, feedback is actually an error string. - - NOTE: this will need to change when we integrate peer grading, because - that will have more complex feedback. - - Output: - String -- html that can be displayed to the student. - """ - - # We want to display available feedback in a particular order. - # This dictionary specifies which goes first--lower first. - priorities = {# These go at the start of the feedback - 'spelling': 0, - 'grammar': 1, - # needs to be after all the other feedback - 'markup_text': 3} - - default_priority = 2 - - def get_priority(elt): - """ - Args: - elt: a tuple of feedback-type, feedback - Returns: - the priority for this feedback type - """ - return priorities.get(elt[0], default_priority) - - - def encode_values(feedback_type,value): - feedback_type=str(feedback_type).encode('ascii', 'ignore') - if not isinstance(value,basestring): - value=str(value) - value=value.encode('ascii', 'ignore') - return feedback_type,value - - def format_feedback(feedback_type, value): - feedback_type,value=encode_values(feedback_type,value) - feedback= """ -
- {value} -
- """.format(feedback_type=feedback_type, value=value) - - return feedback - - def format_feedback_hidden(feedback_type , value): - feedback_type,value=encode_values(feedback_type,value) - feedback = """ - - """.format(feedback_type=feedback_type, value=value) - return feedback - - - # TODO (vshnayder): design and document the details of this format so - # that we can do proper escaping here (e.g. are the graders allowed to - # include HTML?) - - for tag in ['success', 'feedback', 'submission_id', 'grader_id']: - if tag not in response_items: - return format_feedback('errors', 'Error getting feedback') - - feedback_items = response_items['feedback'] - try: - feedback = json.loads(feedback_items) - except (TypeError, ValueError): - log.exception("feedback_items have invalid json %r", feedback_items) - return format_feedback('errors', 'Could not parse feedback') - - if response_items['success']: - if len(feedback) == 0: - return format_feedback('errors', 'No feedback available') - - feedback_lst = sorted(feedback.items(), key=get_priority) - - feedback_list_part1 = u"\n".join(format_feedback(k, v) for k, v in feedback_lst) - else: - feedback_list_part1 = format_feedback('errors', response_items['feedback']) - - feedback_list_part2=(u"\n".join([format_feedback_hidden(feedback_type,value) - for feedback_type,value in response_items.items() - if feedback_type in ['submission_id', 'grader_id']])) - - return u"\n".join([feedback_list_part1,feedback_list_part2]) - - def _format_feedback(self, response_items): - """ - Input: - Dictionary called feedback. Must contain keys seen below. - Output: - Return error message or feedback template - """ - - feedback = self._convert_longform_feedback_to_html(response_items) - - if not response_items['success']: - return self.system.render_template("open_ended_error.html", - {'errors' : feedback}) - - feedback_template = self.system.render_template("open_ended_feedback.html", { - 'grader_type': response_items['grader_type'], - 'score': "{0} / {1}".format(response_items['score'], self.max_score), - 'feedback': feedback, - }) - - return feedback_template - - - def _parse_score_msg(self, score_msg): - """ - Grader reply is a JSON-dump of the following dict - { 'correct': True/False, - 'score': Numeric value (floating point is okay) to assign to answer - 'msg': grader_msg - 'feedback' : feedback from grader - } - - Returns (valid_score_msg, correct, score, msg): - valid_score_msg: Flag indicating valid score_msg format (Boolean) - correct: Correctness of submission (Boolean) - score: Points to be assigned (numeric, can be float) - """ - fail = ScoreMessage(valid=False, correct=False, points=0, msg='') - try: - score_result = json.loads(score_msg) - except (TypeError, ValueError): - log.error("External grader message should be a JSON-serialized dict." - " Received score_msg = {0}".format(score_msg)) - return fail - - if not isinstance(score_result, dict): - log.error("External grader message should be a JSON-serialized dict." - " Received score_result = {0}".format(score_result)) - return fail - - - for tag in ['score', 'feedback', 'grader_type', 'success', 'grader_id', 'submission_id']: - if tag not in score_result: - log.error("External grader message is missing required tag: {0}" - .format(tag)) - return fail - - feedback = self._format_feedback(score_result) - - self.submission_id=score_result['submission_id'] - self.grader_id=score_result['grader_id'] - - # HACK: for now, just assume it's correct if you got more than 2/3. - # Also assumes that score_result['score'] is an integer. - score_ratio = int(score_result['score']) / float(self.max_score) - correct = (score_ratio >= 0.66) - - #Currently ignore msg and only return feedback (which takes the place of msg) - return ScoreMessage(valid=True, correct=correct, - points=score_result['score'], msg=feedback) - -#----------------------------------------------------------------------------- # TEMPORARY: List of all response subclasses # FIXME: To be replaced by auto-registration @@ -2286,5 +1851,4 @@ __all__ = [CodeResponse, ChoiceResponse, MultipleChoiceResponse, TrueFalseResponse, - JavascriptResponse, - OpenEndedResponse] + JavascriptResponse] diff --git a/common/lib/capa/capa/templates/drag_and_drop_input.html b/common/lib/capa/capa/templates/drag_and_drop_input.html new file mode 100644 index 0000000000..c186281796 --- /dev/null +++ b/common/lib/capa/capa/templates/drag_and_drop_input.html @@ -0,0 +1,46 @@ +
+
+
+ + + +
+ + % if status == 'unsubmitted': +
+ % elif status == 'correct': +
+ % elif status == 'incorrect': +
+ % elif status == 'incomplete': +
+ % endif + + + + +

+ % if status == 'unsubmitted': + unanswered + % elif status == 'correct': + correct + % elif status == 'incorrect': + incorrect + % elif status == 'incomplete': + incomplete + % endif +

+ +

+ + % if msg: + ${msg|n} + % endif + + % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
+ % endif +
diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html deleted file mode 100644 index c42ad73faf..0000000000 --- a/common/lib/capa/capa/templates/openendedinput.html +++ /dev/null @@ -1,56 +0,0 @@ -
- - -
- % if status == 'unsubmitted': - Unanswered - % elif status == 'correct': - Correct - % elif status == 'incorrect': - Incorrect - % elif status == 'queued': - Submitted for grading - % endif - - % if hidden: -
- % endif -
- - - - % if status == 'queued': - - % endif -
- ${msg|n} - % if status in ['correct','incorrect']: -
-
- Respond to Feedback -
-
-

How accurate do you find this feedback?

-
-
    -
  • -
  • -
  • -
  • -
  • -
-
-

Additional comments:

- -
- -
-
-
- % endif -
-
diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index dafd31bdc7..6c282baf95 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -9,13 +9,14 @@ TODO: - check rendering -- e.g. msg should appear in the rendered output. If possible, test that templates are escaping things properly. - + - test unicode in values, parameters, etc. - test various html escapes - test funny xml chars -- should never get xml parse error if things are escaped properly. """ +import json from lxml import etree import unittest import xml.sax.saxutils as saxutils @@ -501,3 +502,70 @@ class ChemicalEquationTest(unittest.TestCase): } self.assertEqual(context, expected) + +class DragAndDropTest(unittest.TestCase): + ''' + Check that drag and drop inputs work + ''' + + def test_rendering(self): + path_to_images = '/static/images/' + + xml_str = """ + + + + + + + + + + + + + + + """.format(path=path_to_images) + + element = etree.fromstring(xml_str) + + value = 'abc' + state = {'value': value, + 'status': 'unsubmitted'} + + user_input = { # order matters, for string comparison + "target_outline": "false", + "base_image": "/static/images/about_1.png", + "draggables": [ +{"can_reuse": "", "label": "Label 1", "id": "1", "icon": ""}, +{"can_reuse": "", "label": "cc", "id": "name_with_icon", "icon": "/static/images/cc.jpg", }, +{"can_reuse": "", "label": "arrow-left", "id": "with_icon", "icon": "/static/images/arrow-left.png", "can_reuse": ""}, +{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "can_reuse": ""}, +{"can_reuse": "", "label": "Mute", "id": "2", "icon": "/static/images/mute.png", "can_reuse": ""}, +{"can_reuse": "", "label": "spinner", "id": "name_label_icon3", "icon": "/static/images/spinner.gif", "can_reuse": ""}, +{"can_reuse": "", "label": "Star", "id": "name4", "icon": "/static/images/volume.png", "can_reuse": ""}, +{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "can_reuse": ""}], + "one_per_target": "True", + "targets": [ + {"y": "90", "x": "210", "id": "t1", "w": "90", "h": "90"}, + {"y": "160", "x": "370", "id": "t2", "w": "90", "h": "90"} + ] + } + + the_input = lookup_tag('drag_and_drop_input')(test_system, element, state) + + context = the_input._get_render_context() + expected = {'id': 'prob_1_2', + 'value': value, + 'status': 'unsubmitted', + 'msg': '', + 'drag_and_drop_json': json.dumps(user_input) + } + + # as we are dumping 'draggables' dicts while dumping user_input, string + # comparison will fail, as order of keys is random. + self.assertEqual(json.loads(context['drag_and_drop_json']), user_input) + context.pop('drag_and_drop_json') + expected.pop('drag_and_drop_json') + self.assertEqual(context, expected) diff --git a/common/lib/capa/capa/verifiers/__init__.py b/common/lib/capa/capa/verifiers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/lib/capa/capa/verifiers/draganddrop.py b/common/lib/capa/capa/verifiers/draganddrop.py new file mode 100644 index 0000000000..eb91208923 --- /dev/null +++ b/common/lib/capa/capa/verifiers/draganddrop.py @@ -0,0 +1,376 @@ +""" Grader of drag and drop input. + +Client side behavior: user can drag and drop images from list on base image. + + + Then json returned from client is: + { + "draggable": [ + { "image1": "t1" }, + { "ant": "t2" }, + { "molecule": "t3" }, + ] +} +values are target names. + +or: + { + "draggable": [ + { "image1": "[10, 20]" }, + { "ant": "[30, 40]" }, + { "molecule": "[100, 200]" }, + ] +} +values are (x,y) coordinates of centers of dragged images. +""" + +import json + + +class PositionsCompare(list): + """ Class for comparing positions. + + Args: + list or string:: + "abc" - target + [10, 20] - list of integers + [[10,20], 200] list of list and integer + + """ + def __eq__(self, other): + """ Compares two arguments. + + Default lists behavior is conversion of string "abc" to list + ["a", "b", "c"]. We will use that. + + If self or other is empty - returns False. + + Args: + self, other: str, unicode, list, int, float + + Returns: bool + """ + # checks if self or other is not empty list (empty lists = false) + if not self or not other: + return False + + if (isinstance(self[0], (list, int, float)) and + isinstance(other[0], (list, int, float))): + return self.coordinate_positions_compare(other) + + elif (isinstance(self[0], (unicode, str)) and + isinstance(other[0], (unicode, str))): + return ''.join(self) == ''.join(other) + else: # improper argument types: no (float / int or lists of list + #and float / int pair) or two string / unicode lists pair + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def coordinate_positions_compare(self, other, r=10): + """ Checks if self is equal to other inside radius of forgiveness + (default 10 px). + + Args: + self, other: [x, y] or [[x, y], r], where r is radius of + forgiveness; + x, y, r: int + + Returns: bool. + """ + # get max radius of forgiveness + if isinstance(self[0], list): # [(x, y), r] case + r = max(self[1], r) + x1, y1 = self[0] + else: + x1, y1 = self + + if isinstance(other[0], list): # [(x, y), r] case + r = max(other[1], r) + x2, y2 = other[0] + else: + x2, y2 = other + + if (x2 - x1) ** 2 + (y2 - y1) ** 2 > r * r: + return False + + return True + + +class DragAndDrop(object): + """ Grader class for drag and drop inputtype. + """ + + def grade(self): + ''' Grader user answer. + + Checks if every draggable isplaced on proper target or on proper + coordinates within radius of forgiveness (default is 10). + + Returns: bool. + ''' + for draggable in self.excess_draggables: + if not self.excess_draggables[draggable]: + return False # user answer has more draggables than correct answer + + # Number of draggables in user_groups may be differ that in + # correct_groups, that is incorrect, except special case with 'number' + for groupname, draggable_ids in self.correct_groups.items(): + + # 'number' rule special case + # for reusable draggables we may get in self.user_groups + # {'1': [u'2', u'2', u'2'], '0': [u'1', u'1'], '2': [u'3']} + # if '+number' is in rule - do not remove duplicates and strip + # '+number' from rule + current_rule = self.correct_positions[groupname].keys()[0] + if 'number' in current_rule: + rule_values = self.correct_positions[groupname][current_rule] + # clean rule, do not do clean duplicate items + self.correct_positions[groupname].pop(current_rule, None) + parsed_rule = current_rule.replace('+', '').replace('number', '') + self.correct_positions[groupname][parsed_rule] = rule_values + else: # remove dublicates + self.user_groups[groupname] = list(set(self.user_groups[groupname])) + + if sorted(draggable_ids) != sorted(self.user_groups[groupname]): + return False + + # Check that in every group, for rule of that group, user positions of + # every element are equal with correct positions + for groupname in self.correct_groups: + rules_executed = 0 + for rule in ('exact', 'anyof', 'unordered_equal'): + # every group has only one rule + if self.correct_positions[groupname].get(rule, None): + rules_executed += 1 + if not self.compare_positions( + self.correct_positions[groupname][rule], + self.user_positions[groupname]['user'], flag=rule): + return False + if not rules_executed: # no correct rules for current group + # probably xml content mistake - wrong rules names + return False + + return True + + def compare_positions(self, correct, user, flag): + """ Compares two lists of positions with flag rules. Order of + correct/user arguments is matter only in 'anyof' flag. + + Rules description: + + 'exact' means 1-1 ordered relationship:: + + [el1, el2, el3] is 'exact' equal to [el5, el6, el7] when + el1 == el5, el2 == el6, el3 == el7. + Equality function is custom, see below. + + + 'anyof' means subset relationship:: + + user = [el1, el2] is 'anyof' equal to correct = [el1, el2, el3] + when + set(user) <= set(correct). + + 'anyof' is ordered relationship. It always checks if user + is subset of correct + + Equality function is custom, see below. + + Examples: + + - many draggables per position: + user ['1','2','2','2'] is 'anyof' equal to ['1', '2', '3'] + + - draggables can be placed in any order: + user ['1','2','3','4'] is 'anyof' equal to ['4', '2', '1', 3'] + + 'unordered_equal' is same as 'exact' but disregards on order + + Equality functions: + + Equality functon depends on type of element. They declared in + PositionsCompare class. For position like targets + ids ("t1", "t2", etc..) it is string equality function. For coordinate + positions ([1,2] or [[1,2], 15]) it is coordinate_positions_compare + function (see docstrings in PositionsCompare class) + + Args: + correst, user: lists of positions + + Returns: True if within rule lists are equal, otherwise False. + """ + if flag == 'exact': + if len(correct) != len(user): + return False + for el1, el2 in zip(correct, user): + if PositionsCompare(el1) != PositionsCompare(el2): + return False + + if flag == 'anyof': + for u_el in user: + for c_el in correct: + if PositionsCompare(u_el) == PositionsCompare(c_el): + break + else: + # General: the else is executed after the for, + # only if the for terminates normally (not by a break) + + # In this case, 'for' is terminated normally if every element + # from 'correct' list isn't equal to concrete element from + # 'user' list. So as we found one element from 'user' list, + # that not in 'correct' list - we return False + return False + + if flag == 'unordered_equal': + if len(correct) != len(user): + return False + temp = correct[:] + for u_el in user: + for c_el in temp: + if PositionsCompare(u_el) == PositionsCompare(c_el): + temp.remove(c_el) + break + else: + # same as upper - if we found element from 'user' list, + # that not in 'correct' list - we return False. + return False + + return True + + def __init__(self, correct_answer, user_answer): + """ Populates DragAndDrop variables from user_answer and correct_answer. + If correct_answer is dict, converts it to list. + Correct answer in dict form is simpe structure for fast and simple + grading. Example of correct answer dict example:: + + correct_answer = {'name4': 't1', + 'name_with_icon': 't1', + '5': 't2', + '7':'t2'} + + It is draggable_name: dragable_position mapping. + + Advanced form converted from simple form uses 'exact' rule + for matching. + + Correct answer in list form is designed for advanced cases:: + + correct_answers = [ + { + 'draggables': ['1', '2', '3', '4', '5', '6'], + 'targets': [ + 's_left', 's_right', 's_sigma', 's_sigma_star', 'p_pi_1', 'p_pi_2'], + 'rule': 'anyof'}, + { + 'draggables': ['7', '8', '9', '10'], + 'targets': ['p_left_1', 'p_left_2', 'p_right_1', 'p_right_2'], + 'rule': 'anyof' + } + ] + + Advanced answer in list form is list of dicts, and every dict must have + 3 keys: 'draggables', 'targets' and 'rule'. 'Draggables' value is + list of draggables ids, 'targes' values are list of targets ids, 'rule' + value one of 'exact', 'anyof', 'unordered_equal', 'anyof+number', + 'unordered_equal+number' + + Advanced form uses "all dicts must match with their rule" logic. + + Same draggable cannot appears more that in one dict. + + Behavior is more widely explained in sphinx documentation. + + Args: + user_answer: json + correct_answer: dict or list + """ + + self.correct_groups = dict() # correct groups from xml + self.correct_positions = dict() # correct positions for comparing + self.user_groups = dict() # will be populated from user answer + self.user_positions = dict() # will be populated from user answer + + # convert from dict answer format to list format + if isinstance(correct_answer, dict): + tmp = [] + for key, value in correct_answer.items(): + tmp_dict = {'draggables': [], 'targets': [], 'rule': 'exact'} + tmp_dict['draggables'].append(key) + tmp_dict['targets'].append(value) + tmp.append(tmp_dict) + correct_answer = tmp + + user_answer = json.loads(user_answer) + + # check if we have draggables that are not in correct answer: + self.excess_draggables = {} + + # create identical data structures from user answer and correct answer + for i in xrange(0, len(correct_answer)): + groupname = str(i) + self.correct_groups[groupname] = correct_answer[i]['draggables'] + self.correct_positions[groupname] = {correct_answer[i]['rule']: + correct_answer[i]['targets']} + self.user_groups[groupname] = [] + self.user_positions[groupname] = {'user': []} + for draggable_dict in user_answer['draggables']: + # draggable_dict is 1-to-1 {draggable_name: position} + draggable_name = draggable_dict.keys()[0] + if draggable_name in self.correct_groups[groupname]: + self.user_groups[groupname].append(draggable_name) + self.user_positions[groupname]['user'].append( + draggable_dict[draggable_name]) + self.excess_draggables[draggable_name] = True + else: + self.excess_draggables[draggable_name] = \ + self.excess_draggables.get(draggable_name, False) + + +def grade(user_input, correct_answer): + """ Creates DragAndDrop instance from user_input and correct_answer and + calls DragAndDrop.grade for grading. + + Supports two interfaces for correct_answer: dict and list. + + Args: + user_input: json. Format:: + + { "draggables": + [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + + or + + {"draggables": [{"1": "t1"}, \ + {"name_with_icon": "t2"}]} + + correct_answer: dict or list. + + Dict form:: + + {'1': 't1', 'name_with_icon': 't2'} + + or + + {'1': '[10, 10]', 'name_with_icon': '[[10, 10], 20]'} + + List form:: + + correct_answer = [ + { + 'draggables': ['l3_o', 'l10_o'], + 'targets': ['t1_o', 't9_o'], + 'rule': 'anyof' + }, + { + 'draggables': ['l1_c','l8_c'], + 'targets': ['t5_c','t6_c'], + 'rule': 'anyof' + } + ] + + Returns: bool + """ + return DragAndDrop(correct_answer=correct_answer, + user_answer=user_input).grade() diff --git a/common/lib/capa/capa/verifiers/tests_draganddrop.py b/common/lib/capa/capa/verifiers/tests_draganddrop.py new file mode 100644 index 0000000000..9b1b15ce0c --- /dev/null +++ b/common/lib/capa/capa/verifiers/tests_draganddrop.py @@ -0,0 +1,603 @@ +import unittest + +import draganddrop +from draganddrop import PositionsCompare + + +class Test_PositionsCompare(unittest.TestCase): + """ describe""" + + def test_nested_list_and_list1(self): + self.assertEqual(PositionsCompare([[1, 2], 40]), PositionsCompare([1, 3])) + + def test_nested_list_and_list2(self): + self.assertNotEqual(PositionsCompare([1, 12]), PositionsCompare([1, 1])) + + def test_list_and_list1(self): + self.assertNotEqual(PositionsCompare([[1, 2], 12]), PositionsCompare([1, 15])) + + def test_list_and_list2(self): + self.assertEqual(PositionsCompare([1, 11]), PositionsCompare([1, 1])) + + def test_numerical_list_and_string_list(self): + self.assertNotEqual(PositionsCompare([1, 2]), PositionsCompare(["1"])) + + def test_string_and_string_list1(self): + self.assertEqual(PositionsCompare("1"), PositionsCompare(["1"])) + + def test_string_and_string_list2(self): + self.assertEqual(PositionsCompare("abc"), PositionsCompare("abc")) + + def test_string_and_string_list3(self): + self.assertNotEqual(PositionsCompare("abd"), PositionsCompare("abe")) + + def test_float_and_string(self): + self.assertNotEqual(PositionsCompare([3.5, 5.7]), PositionsCompare(["1"])) + + def test_floats_and_ints(self): + self.assertEqual(PositionsCompare([3.5, 4.5]), PositionsCompare([5, 7])) + + +class Test_DragAndDrop_Grade(unittest.TestCase): + + def test_targets_true(self): + user_input = '{"draggables": [{"1": "t1"}, \ + {"name_with_icon": "t2"}]}' + correct_answer = {'1': 't1', 'name_with_icon': 't2'} + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_targets_false(self): + user_input = '{"draggables": [{"1": "t1"}, \ + {"name_with_icon": "t2"}]}' + correct_answer = {'1': 't3', 'name_with_icon': 't2'} + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_multiple_images_per_target_true(self): + user_input = '{\ + "draggables": [{"1": "t1"}, {"name_with_icon": "t2"}, \ + {"2": "t1"}]}' + correct_answer = {'1': 't1', 'name_with_icon': 't2', + '2': 't1'} + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_multiple_images_per_target_false(self): + user_input = '{\ + "draggables": [{"1": "t1"}, {"name_with_icon": "t2"}, \ + {"2": "t1"}]}' + correct_answer = {'1': 't2', 'name_with_icon': 't2', + '2': 't1'} + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_targets_and_positions(self): + user_input = '{"draggables": [{"1": [10,10]}, \ + {"name_with_icon": [[10,10],4]}]}' + correct_answer = {'1': [10, 10], 'name_with_icon': [[10, 10], 4]} + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_position_and_targets(self): + user_input = '{"draggables": [{"1": "t1"}, {"name_with_icon": "t2"}]}' + correct_answer = {'1': 't1', 'name_with_icon': 't2'} + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_positions_exact(self): + user_input = '{"draggables": \ + [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + correct_answer = {'1': [10, 10], 'name_with_icon': [20, 20]} + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_positions_false(self): + user_input = '{"draggables": \ + [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + correct_answer = {'1': [25, 25], 'name_with_icon': [20, 20]} + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_positions_true_in_radius(self): + user_input = '{"draggables": \ + [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + correct_answer = {'1': [14, 14], 'name_with_icon': [20, 20]} + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_positions_true_in_manual_radius(self): + user_input = '{"draggables": \ + [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + correct_answer = {'1': [[40, 10], 30], 'name_with_icon': [20, 20]} + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_positions_false_in_manual_radius(self): + user_input = '{"draggables": \ + [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + correct_answer = {'1': [[40, 10], 29], 'name_with_icon': [20, 20]} + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_correct_answer_not_has_key_from_user_answer(self): + user_input = '{"draggables": [{"1": "t1"}, \ + {"name_with_icon": "t2"}]}' + correct_answer = {'3': 't3', 'name_with_icon': 't2'} + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_anywhere(self): + """Draggables can be places anywhere on base image. + Place grass in the middle of the image and ant in the + right upper corner.""" + user_input = '{"draggables": \ + [{"ant":[610.5,57.449951171875]},{"grass":[322.5,199.449951171875]}]}' + + correct_answer = {'grass': [[300, 200], 200], 'ant': [[500, 0], 200]} + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_lcao_correct(self): + """Describe carbon molecule in LCAO-MO""" + user_input = '{"draggables":[{"1":"s_left"}, \ + {"5":"s_right"},{"4":"s_sigma"},{"6":"s_sigma_star"},{"7":"p_left_1"}, \ + {"8":"p_left_2"},{"10":"p_right_1"},{"9":"p_right_2"}, \ + {"2":"p_pi_1"},{"3":"p_pi_2"},{"11":"s_sigma_name"}, \ + {"13":"s_sigma_star_name"},{"15":"p_pi_name"},{"16":"p_pi_star_name"}, \ + {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]}' + + correct_answer = [{ + 'draggables': ['1', '2', '3', '4', '5', '6'], + 'targets': [ + 's_left', 's_right', 's_sigma', 's_sigma_star', 'p_pi_1', 'p_pi_2' + ], + 'rule': 'anyof' + }, { + 'draggables': ['7', '8', '9', '10'], + 'targets': ['p_left_1', 'p_left_2', 'p_right_1', 'p_right_2'], + 'rule': 'anyof' + }, { + 'draggables': ['11', '12'], + 'targets': ['s_sigma_name', 'p_sigma_name'], + 'rule': 'anyof' + }, { + 'draggables': ['13', '14'], + 'targets': ['s_sigma_star_name', 'p_sigma_star_name'], + 'rule': 'anyof' + }, { + 'draggables': ['15'], + 'targets': ['p_pi_name'], + 'rule': 'anyof' + }, { + 'draggables': ['16'], + 'targets': ['p_pi_star_name'], + 'rule': 'anyof' + }] + + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_lcao_extra_element_incorrect(self): + """Describe carbon molecule in LCAO-MO""" + user_input = '{"draggables":[{"1":"s_left"}, \ + {"5":"s_right"},{"4":"s_sigma"},{"6":"s_sigma_star"},{"7":"p_left_1"}, \ + {"8":"p_left_2"},{"17":"p_left_3"},{"10":"p_right_1"},{"9":"p_right_2"}, \ + {"2":"p_pi_1"},{"3":"p_pi_2"},{"11":"s_sigma_name"}, \ + {"13":"s_sigma_star_name"},{"15":"p_pi_name"},{"16":"p_pi_star_name"}, \ + {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]}' + + correct_answer = [{ + 'draggables': ['1', '2', '3', '4', '5', '6'], + 'targets': [ + 's_left', 's_right', 's_sigma', 's_sigma_star', 'p_pi_1', 'p_pi_2' + ], + 'rule': 'anyof' + }, { + 'draggables': ['7', '8', '9', '10'], + 'targets': ['p_left_1', 'p_left_2', 'p_right_1', 'p_right_2'], + 'rule': 'anyof' + }, { + 'draggables': ['11', '12'], + 'targets': ['s_sigma_name', 'p_sigma_name'], + 'rule': 'anyof' + }, { + 'draggables': ['13', '14'], + 'targets': ['s_sigma_star_name', 'p_sigma_star_name'], + 'rule': 'anyof' + }, { + 'draggables': ['15'], + 'targets': ['p_pi_name'], + 'rule': 'anyof' + }, { + 'draggables': ['16'], + 'targets': ['p_pi_star_name'], + 'rule': 'anyof' + }] + + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_reuse_draggable_no_mupliples(self): + """Test reusable draggables (no mupltiple draggables per target)""" + user_input = '{"draggables":[{"1":"target1"}, \ + {"2":"target2"},{"1":"target3"},{"2":"target4"},{"2":"target5"}, \ + {"3":"target6"}]}' + correct_answer = [ + { + 'draggables': ['1'], + 'targets': ['target1', 'target3'], + 'rule': 'anyof' + }, + { + 'draggables': ['2'], + 'targets': ['target2', 'target4', 'target5'], + 'rule': 'anyof' + }, + { + 'draggables': ['3'], + 'targets': ['target6'], + 'rule': 'anyof' + }] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_reuse_draggable_with_mupliples(self): + """Test reusable draggables with mupltiple draggables per target""" + user_input = '{"draggables":[{"1":"target1"}, \ + {"2":"target2"},{"1":"target1"},{"2":"target4"},{"2":"target4"}, \ + {"3":"target6"}]}' + correct_answer = [ + { + 'draggables': ['1'], + 'targets': ['target1', 'target3'], + 'rule': 'anyof' + }, + { + 'draggables': ['2'], + 'targets': ['target2', 'target4'], + 'rule': 'anyof' + }, + { + 'draggables': ['3'], + 'targets': ['target6'], + 'rule': 'anyof' + }] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_reuse_many_draggable_with_mupliples(self): + """Test reusable draggables with mupltiple draggables per target""" + user_input = '{"draggables":[{"1":"target1"}, \ + {"2":"target2"},{"1":"target1"},{"2":"target4"},{"2":"target4"}, \ + {"3":"target6"}, {"4": "target3"}, {"5": "target4"}, \ + {"5": "target5"}, {"6": "target2"}]}' + correct_answer = [ + { + 'draggables': ['1', '4'], + 'targets': ['target1', 'target3'], + 'rule': 'anyof' + }, + { + 'draggables': ['2', '6'], + 'targets': ['target2', 'target4'], + 'rule': 'anyof' + }, + { + 'draggables': ['5'], + 'targets': ['target4', 'target5'], + 'rule': 'anyof' + }, + { + 'draggables': ['3'], + 'targets': ['target6'], + 'rule': 'anyof' + }] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_reuse_many_draggable_with_mupliples_wrong(self): + """Test reusable draggables with mupltiple draggables per target""" + user_input = '{"draggables":[{"1":"target1"}, \ + {"2":"target2"},{"1":"target1"}, \ + {"2":"target3"}, \ + {"2":"target4"}, \ + {"3":"target6"}, {"4": "target3"}, {"5": "target4"}, \ + {"5": "target5"}, {"6": "target2"}]}' + correct_answer = [ + { + 'draggables': ['1', '4'], + 'targets': ['target1', 'target3'], + 'rule': 'anyof' + }, + { + 'draggables': ['2', '6'], + 'targets': ['target2', 'target4'], + 'rule': 'anyof' + }, + { + 'draggables': ['5'], + 'targets': ['target4', 'target5'], + 'rule': 'anyof' + }, + { + 'draggables': ['3'], + 'targets': ['target6'], + 'rule': 'anyof' + }] + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_label_10_targets_with_a_b_c_false(self): + """Test reusable draggables (no mupltiple draggables per target)""" + user_input = '{"draggables":[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \ + {"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \ + {"a":"target1"}]}' + correct_answer = [ + { + 'draggables': ['a'], + 'targets': ['target1', 'target4', 'target7', 'target10'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['b'], + 'targets': ['target2', 'target5', 'target8'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['c'], + 'targets': ['target3', 'target6', 'target9'], + 'rule': 'unordered_equal' + }] + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_label_10_targets_with_a_b_c_(self): + """Test reusable draggables (no mupltiple draggables per target)""" + user_input = '{"draggables":[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \ + {"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \ + {"a":"target10"}]}' + correct_answer = [ + { + 'draggables': ['a'], + 'targets': ['target1', 'target4', 'target7', 'target10'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['b'], + 'targets': ['target2', 'target5', 'target8'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['c'], + 'targets': ['target3', 'target6', 'target9'], + 'rule': 'unordered_equal' + }] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_label_10_targets_with_a_b_c_multiple(self): + """Test reusable draggables (mupltiple draggables per target)""" + user_input = '{"draggables":[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"},{"b":"target5"}, \ + {"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \ + {"a":"target1"}]}' + correct_answer = [ + { + 'draggables': ['a', 'a', 'a'], + 'targets': ['target1', 'target4', 'target7', 'target10'], + 'rule': 'anyof+number' + }, + { + 'draggables': ['b', 'b', 'b'], + 'targets': ['target2', 'target5', 'target8'], + 'rule': 'anyof+number' + }, + { + 'draggables': ['c', 'c', 'c'], + 'targets': ['target3', 'target6', 'target9'], + 'rule': 'anyof+number' + }] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_label_10_targets_with_a_b_c_multiple_false(self): + """Test reusable draggables (mupltiple draggables per target)""" + user_input = '{"draggables":[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \ + {"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \ + {"a":"target1"}]}' + correct_answer = [ + { + 'draggables': ['a', 'a', 'a'], + 'targets': ['target1', 'target4', 'target7', 'target10'], + 'rule': 'anyof+number' + }, + { + 'draggables': ['b', 'b', 'b'], + 'targets': ['target2', 'target5', 'target8'], + 'rule': 'anyof+number' + }, + { + 'draggables': ['c', 'c', 'c'], + 'targets': ['target3', 'target6', 'target9'], + 'rule': 'anyof+number' + }] + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_label_10_targets_with_a_b_c_reused(self): + """Test a b c in 10 labels reused""" + user_input = '{"draggables":[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"},{"b":"target5"}, \ + {"c":"target6"}, {"b":"target8"},{"c":"target9"}, \ + {"a":"target10"}]}' + correct_answer = [ + { + 'draggables': ['a', 'a'], + 'targets': ['target1', 'target10'], + 'rule': 'unordered_equal+number' + }, + { + 'draggables': ['b', 'b', 'b'], + 'targets': ['target2', 'target5', 'target8'], + 'rule': 'unordered_equal+number' + }, + { + 'draggables': ['c', 'c', 'c'], + 'targets': ['target3', 'target6', 'target9'], + 'rule': 'unordered_equal+number' + }] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_label_10_targets_with_a_b_c_reused_false(self): + """Test a b c in 10 labels reused false""" + user_input = '{"draggables":[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"},{"b":"target5"}, {"a":"target8"},\ + {"c":"target6"}, {"b":"target8"},{"c":"target9"}, \ + {"a":"target10"}]}' + correct_answer = [ + { + 'draggables': ['a', 'a'], + 'targets': ['target1', 'target10'], + 'rule': 'unordered_equal+number' + }, + { + 'draggables': ['b', 'b', 'b'], + 'targets': ['target2', 'target5', 'target8'], + 'rule': 'unordered_equal+number' + }, + { + 'draggables': ['c', 'c', 'c'], + 'targets': ['target3', 'target6', 'target9'], + 'rule': 'unordered_equal+number' + }] + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_mixed_reuse_and_not_reuse(self): + """Test reusable draggables """ + user_input = '{"draggables":[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"}, {"a":"target4"},\ + {"a":"target5"}]}' + correct_answer = [ + { + 'draggables': ['a', 'b'], + 'targets': ['target1', 'target2', 'target4', 'target5'], + 'rule': 'anyof' + }, + { + 'draggables': ['c'], + 'targets': ['target3'], + 'rule': 'exact' + }] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_mixed_reuse_and_not_reuse_number(self): + """Test reusable draggables with number """ + user_input = '{"draggables":[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"}, {"a":"target4"}]}' + correct_answer = [ + { + 'draggables': ['a', 'a', 'b'], + 'targets': ['target1', 'target2', 'target4'], + 'rule': 'anyof+number' + }, + { + 'draggables': ['c'], + 'targets': ['target3'], + 'rule': 'exact' + }] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_mixed_reuse_and_not_reuse_number_false(self): + """Test reusable draggables with numbers, but wrong""" + user_input = '{"draggables":[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"}, {"a":"target4"}, {"a":"target10"}]}' + correct_answer = [ + { + 'draggables': ['a', 'a', 'b'], + 'targets': ['target1', 'target2', 'target4', 'target10'], + 'rule': 'anyof_number' + }, + { + 'draggables': ['c'], + 'targets': ['target3'], + 'rule': 'exact' + }] + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_alternative_correct_answer(self): + user_input = '{"draggables":[{"name_with_icon":"t1"},\ + {"name_with_icon":"t1"},{"name_with_icon":"t1"},{"name4":"t1"}, \ + {"name4":"t1"}]}' + correct_answer = [ + {'draggables': ['name4'], 'targets': ['t1', 't1'], 'rule': 'exact'}, + {'draggables': ['name_with_icon'], 'targets': ['t1', 't1', 't1'], + 'rule': 'exact'} + ] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + +class Test_DragAndDrop_Populate(unittest.TestCase): + + def test_1(self): + correct_answer = {'1': [[40, 10], 29], 'name_with_icon': [20, 20]} + user_input = '{"draggables": \ + [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + dnd = draganddrop.DragAndDrop(correct_answer, user_input) + + correct_groups = {'1': ['name_with_icon'], '0': ['1']} + correct_positions = {'1': {'exact': [[20, 20]]}, '0': {'exact': [[[40, 10], 29]]}} + user_groups = {'1': [u'name_with_icon'], '0': [u'1']} + user_positions = {'1': {'user': [[20, 20]]}, '0': {'user': [[10, 10]]}} + + self.assertEqual(correct_groups, dnd.correct_groups) + self.assertEqual(correct_positions, dnd.correct_positions) + self.assertEqual(user_groups, dnd.user_groups) + self.assertEqual(user_positions, dnd.user_positions) + + +class Test_DraAndDrop_Compare_Positions(unittest.TestCase): + + def test_1(self): + dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + self.assertTrue(dnd.compare_positions(correct=[[1, 1], [2, 3]], + user=[[2, 3], [1, 1]], + flag='anyof')) + + def test_2a(self): + dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + self.assertTrue(dnd.compare_positions(correct=[[1, 1], [2, 3]], + user=[[2, 3], [1, 1]], + flag='exact')) + + def test_2b(self): + dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + self.assertFalse(dnd.compare_positions(correct=[[1, 1], [2, 3]], + user=[[2, 13], [1, 1]], + flag='exact')) + + def test_3(self): + dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + self.assertFalse(dnd.compare_positions(correct=["a", "b"], + user=["a", "b", "c"], + flag='anyof')) + + def test_4(self): + dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + self.assertTrue(dnd.compare_positions(correct=["a", "b", "c"], + user=["a", "b"], + flag='anyof')) + + def test_5(self): + dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + self.assertFalse(dnd.compare_positions(correct=["a", "b", "c"], + user=["a", "c", "b"], + flag='exact')) + + def test_6(self): + dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + self.assertTrue(dnd.compare_positions(correct=["a", "b", "c"], + user=["a", "c", "b"], + flag='anyof')) + + def test_7(self): + dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + self.assertFalse(dnd.compare_positions(correct=["a", "b", "b"], + user=["a", "c", "b"], + flag='anyof')) + + +def suite(): + + testcases = [Test_PositionsCompare, + Test_DragAndDrop_Populate, + Test_DragAndDrop_Grade, + Test_DraAndDrop_Compare_Positions + ] + suites = [] + for testcase in testcases: + suites.append(unittest.TestLoader().loadTestsFromTestCase(testcase)) + return unittest.TestSuite(suites) + +if __name__ == "__main__": + unittest.TextTestRunner(verbosity=2).run(suite()) diff --git a/common/lib/xmodule/jasmine_test_runner.html.erb b/common/lib/xmodule/jasmine_test_runner.html.erb index 5ee06b5f1b..3327ab4aea 100644 --- a/common/lib/xmodule/jasmine_test_runner.html.erb +++ b/common/lib/xmodule/jasmine_test_runner.html.erb @@ -16,6 +16,7 @@ + + +

Help


-
-

I tried to sign up, but it says the username is already taken.

-

If you have previously signed up for an MITx account, you already have an edX account and can log in with your existing username and password. If you don’t have an MITx account and received this error, it's possible that someone else has already signed up with that username. Please try a different, more unique username – for example, try adding a random number to the end.

-
-
-

How will I know that the course I have signed up for has started?

-

The start date for each course is listed on the right-hand side of the Course About page.

-
-
-

I just signed up into edX. I have not received any form of acknowledgement that I have enrolled.

-

You should receive a single activation e-mail. If you did not, it may be because:

-
    -
  • There was a typo in your e-mail address.
  • -
  • The activation e-mail was caught by your spam filter. Please check your spam folder.
  • -
  • You may be using an older browser. We recommend downloading the current version of Firefox or Chrome.
  • -
  • JavaScript is disabled in your browser. Please confirm it is enabled.
  • -
  • If you run into issues, try recreating your account. There is no need to do anything about the old account, if any. If it is not activated through the link in the e-mail, it will disappear later.
  • -
-
-
+
+
+

edX Basics

+
+

How do I sign up to take a class?

+
+

Simply create an edX account (it's free) and then register for the course of your choice (also free). Follow the prompts on the edX website.

+
+
+
+

What does it cost to take a class? Is this really free?

+
+

EdX courses are free for everyone. All you need is an Internet connection.

+
+
+
+

What happens after I sign up for a course?

+
+

You will receive an activation email. Follow the prompts in that email to activate your account. You will need to log in each time you access your course(s). Once the course begins, it’s time to hit the virtual books. You can access the lectures, homework, tutorials, etc., for each week, one week at a time.

+
+
+
+

Who can take an edX course?

+
+

You, your mom, your little brother, your grandfather -- anyone with Internet access can take an edX course. Free.

+
+
+
+

Are the courses only offered in English?

+
+

Some edX courses include a translation of the lecture in the text bar to the right of the video. Some have the specific option of requesting a course in other languages. Please check your course to determine foreign language options.

+
+
+
+

When will there be more courses on other subjects?

+
+

We are continually reviewing and creating courses to add to the edX platform. Please check the website for future course announcements. You can also "friend" edX on Facebook – you’ll receive updates and announcements.

+
+
+
+

How can I help edX?

+
+

You may not realize it, but just by taking a course you are helping edX. That’s because the edX platform has been specifically designed to not only teach, but also gather data about learning. EdX will utilize this data to find out how to improve education online and on-campus.

+
+
+
+

When does my course start and/or finish?

+
+

You can find the start and stop dates for each course on each course description page.

+
+
+
+

Is there a walk-through of a sample course session?

+
+

There are video introductions for every course that will give you a good sense of how the course works and what to expect.

+
+
+
+

I don't have the prerequisites for a course that I am interested in. Can I still take the course?

+
+

We do not check students for prerequisites, so you are allowed to attempt the course. However, if you do not know prerequisite subjects before taking a class, you will have to learn the prerequisite material on your own over the semester, which can be an extremely difficult task.

+
+
+
+

What happens if I have to quit a course, are there any penalties, will I be able to take another course in the future?

+
+

You may unregister from an edX course at anytime, there are absolutely no penalties associated with incomplete edX studies, and you may register for the same course (provided we are still offering it) at a later time.

+
+
+
-
-

Help email

+
+

The Classes

+
+

How much work will I have to do to pass my course?

+
+

The projected hours of study required for each course are described on the specific course description page.

+
+
+
+

What should I do before I take a course (prerequisites)?

+
+

Each course is different – some have prerequisites, and some don’t. Take a look at your specific course’s recommended prerequisites. If you do not have a particular prerequisite, you may still take the course.

+
+
+
+

What books should I read? (I am interested in reading materials before the class starts).

+
+

Take a look at the specific course prerequisites. All required academic materials will be provided during the course, within the browser. Some of the course descriptions may list additional resources. For supplemental reading material before or during the course, you can post a question on the course’s Discussion Forum to ask your online coursemates for suggestions.

+
+
+
+

Can I download the book for my course?

+
+

EdX book content may only be viewed within the browser, and downloading it violates copyright laws. If you need or want a hard copy of the book, we recommend that you purchase a copy.

+
+
+
+

Can I take more than one course at a time?

+
+

You may take multiple edX courses, however we recommend checking the requirements on each course description page to determine your available study hours and the demands of the intended courses.

+
+
+
+

How do I log in to take an edX class?

+
+

Once you sign up for a course and activate your account, click on the "Log In" button on the edx.org home page. You will need to type in your email address and edX password each time you log in.

+
+
+
+

What time is the class?

+
+

EdX classes take place at your convenience. Prefer to sleep in and study late? No worries. Videos and problem sets are available 24 hours a day, which means you can watch video and complete work whenever you have spare time. You simply log in to your course via the Internet and work through the course material, one week at a time.

+
+
+
+

If I miss a week, how does this affect my grade?

+
+

It is certainly possible to pass an edX course if you miss a week; however, coursework is progressive, so you should review and study what you may have missed. You can check your progress dashboard in the course to see your course average along the way if you have any concerns.

+
+
+
+

How can I meet/find other students?

+
+

All edX courses have Discussion Forums where you can chat with and help each other within the framework of the Honor Code.

+
+
+
+

How can I talk to professors, fellows and teaching assistants?

+
+

The Discussion Forums are the best place to reach out to the edX teaching team for your class, and you don’t have to wait in line or rearrange your schedule to fit your professor’s – just post your questions. The response isn’t always immediate, but it’s usually pretty darned quick.

+
+
+
+ +
+

Getting Help

+
+

Can I re-take a course?

+
+

Good news: there are unlimited "mulligans" in edX. You may re-take edX courses as often as you wish. Your performance in any particular offering of a course will not effect your standing in future offerings of any edX course, including future offerings of the same course.

+
+
+
+

Enrollment for a course that I am interested in is open, but the course has already started. Can I still enroll?

+
+

Yes, but you will not be able to turn in any assignments or exams that have already been due. If it is early in the course, you might still be able to earn enough points for a certificate, but you will have to check with the course in question in order to find out more.

+
+
+
+

Is there an exam at the end?

+
+

Different courses have slightly different structures. Please check the course material description to see if there is a final exam or final project.

+
+
+
+

Will the same courses be offered again in the future?

+
+

Existing edX courses will be re-offered, and more courses added.

+
+
+
+

Will I get a certificate for taking an edX course?

+
+

Online learners who receive a passing grade for a course will receive a certificate of mastery from edX and the underlying X University that offered the course. For example, a certificate of mastery for MITx’s 6.002x Circuits & Electronics will come from edX and MITx.

+
+
+
+

How are edX certificates delivered?

+
+

EdX certificates are delivered online through edx.org. So be sure to check your email in the weeks following the final grading – you will be able to download and print your certificate.

+
+
+
+

What is the difference between a proctored certificate and an honor code certificate?

+
+

A proctored certificate is given to students who take and pass an exam under proctored conditions. An honor-code certificate is given to students who have completed all of the necessary online coursework associated with a course and have signed the edX honor code .

+
+
+
+

Yes. The requirements for both certificates can be independently satisfied.

+
+

It is certainly possible to pass an edX course if you miss a week; however, coursework is progressive, so you should review and study what you may have missed. You can check your progress dashboard in the course to see your course average along the way if you have any concerns.

+
+
+
+

Will my grade be shown on my certificate?

+
+

No. Grades are not displayed on either honor code or proctored certificates.

+
+
+
+

How can I talk to professors, fellows and teaching assistants?

+
+

The Discussion Forums are the best place to reach out to the edX teaching team for your class, and you don’t have to wait in line or rearrange your schedule to fit your professor’s – just post your questions. The response isn’t always immediate, but it’s usually pretty darned quick.

+
+
+
+

The only certificates distributed with grades by edX were for the initial prototype course.

+
+

You may unregister from an edX course at anytime, there are absolutely no penalties associated with incomplete edX studies, and you may register for the same course (provided we are still offering it) at a later time.

+
+
+
+

Will my university accept my edX coursework for credit?

+
+

Each educational institution makes its own decision regarding whether to accept edX coursework for credit. Check with your university for its policy.

+
+
+
+

I lost my edX certificate – can you resend it to me?

+
+

Please log back in to your account to find certificates from the same profile page where they were originally posted. You will be able to re-print your certificate from there.

+
+
+
+ +
+

edX & Open Source

+
+

What’s open source?

+
+

Open source is a philosophy that generally refers to making software freely available for use or modification as users see fit. In exchange for use of the software, users generally add their contributions to the software, making it a public collaboration. The edX platform will be made available as open source code in order to allow world talent to improve and share it on an ongoing basis.

+
+
+
+

When/how can I get the open-source platform technology?

+
+

We are still building the edX technology platform and will be making announcements in the future about its availability.

+
+
+
+ +
+

Other Help Questions - Account Questions

+
+

My username is taken.

+
+

Now’s your chance to be creative: please try a different, more unique username – for example, try adding a random number to the end.

+
+
+
+

Why does my password show on my course login page?

+
+

Oops! This may be because of the way you created your account. For example, you may have mistakenly typed your password into the login box.

+
+
+
+

I am having login problems (password/email unrecognized).

+
+

Please check your browser’s settings to make sure that you have the current version of Firefox or Chrome, and then try logging in again. If you find access impossible, you may simply create a new account using an alternate email address – the old, unused account will disappear later.

+
+
+
+

I did not receive an activation email.

+
+

If you did not receive an activation email it may be because:

+
    +
  • There was a typo in your email address.
  • +
  • Your spam filter may have caught the activation email. Please check your spam folder.
  • +
  • You may be using an older browser. We recommend downloading the current version of Firefox or Chrome.
  • +
  • JavaScript is disabled in your browser. Please check your browser settings and confirm that JavaScript is enabled.
  • +
+

If you continue to have problems activating your account, we recommend that you try creating a new account. There is no need to do anything about the old account. If it is not activated through the link in the email, it will disappear later.

+
+
+
+

Can I delete my account?

+
+

There’s no need to delete you account. An old, unused edX account with no course completions associated with it will disappear.

+
+
+
+

I am experiencing problems with the display. E.g., There are tools missing from the course display, or I am unable to view video.

+
+

Please check your browser and settings. We recommend downloading the current version of Firefox or Chrome. Alternatively, you may re-register with a different email account. There is no need to delete the old account, as it will disappear if unused.

+
+
+
+ +
+ + -
diff --git a/lms/templates/static_templates/media-kit.html b/lms/templates/static_templates/media-kit.html index 458cfb8e15..73eea9c3b8 100644 --- a/lms/templates/static_templates/media-kit.html +++ b/lms/templates/static_templates/media-kit.html @@ -89,7 +89,7 @@
-
Screenshot of 6.00x: Introduction to Computer Science and Programming.
+
Screenshot of 3.091x: Introduction to Solid State Chemistry.
Download (High Resolution Photo)
@@ -108,4 +108,4 @@ return false; }); - \ No newline at end of file + diff --git a/lms/templates/static_templates/press_releases/Lewin_course_announcement.html b/lms/templates/static_templates/press_releases/Lewin_course_announcement.html new file mode 100644 index 0000000000..4fb2a2c83e --- /dev/null +++ b/lms/templates/static_templates/press_releases/Lewin_course_announcement.html @@ -0,0 +1,77 @@ +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../../main.html" /> + +<%namespace name='static' file='../../static_content.html'/> + +<%block name="title">New Course from legendary MIT physics professor Walter Lewin +
+ + +
+
+

Afraid of physics? Do you hate it?
Walter Lewin will make you love physics whether you like it or not

+
+
+

MIT physics professor and online web star brings his renowned Electricity and Magnetism course to edX

+ +
+ +
+

Walter Lewin, legendary MIT physics professor, demonstrates, in his inimitable fashion, one of the many laws of physics covered in his new course on edX.

+

Credit: Dominick Reuter

+ High Resolution Image

+
+
+ +

CAMBRIDGE, MA – January 22, 2013 – EdX, the not-for-profit online learning initiative founded by Harvard University and the Massachusetts Institute of Technology (MIT), announced today a new course from the legendary Professor Walter Lewin who, for 47 years, has provided generations of MIT students – and millions watching online – with his inspiring and unconventional lectures. Now, with this edX version of Professor Lewin’s famous course Electricity and Magnetism (Physics), people around the world can experience it just like his students on the MIT campus. MITx 8.02x Electricity and Magnetism is now open for enrollment and classes will begin on February 18, 2013.

+ +

“I have taught this course to tens of thousands and many tell me it changed their lives,” said Walter Lewin, Professor of Physics at MIT. “Teaching is my passion: I want to open peoples’ eyes and minds to the beauty of physics so they will begin to see the world in a new way.”

+ +

In 8.02x Electricity and Magnetism, Professor Lewin will teach students to “see” the world instead of just “looking at” it. He will make them “see” natural phenomena such as rainbows in a way they never imagined before. Through his dynamic teaching, enthusiasm and great sense of humor, Professor Lewin has an innate ability to make difficult concepts easy. The New York Times has crowned him a “Web Star” and noted how his lectures, with their engaging physics demonstrations, have won him devotees around the world. While this course is MIT level, edX and Professor Lewin encourage even senior high school students from around the world to watch his lectures and take the course.

+ +

“Walter Lewin is an international treasure,” said Anant Agarwal, President of edX. “His physics lectures on the MIT campus were already legendary before he put them online and they became an international sensation. We know edX learners will be awestruck by his provocative and enlightening course.”

+ +

In addition to the basic concepts of Electromagnetism, a vast variety of interesting topics are covered, including Lightning, Pacemakers, Electric Shock Treatment, Electrocardiograms, Metal Detectors, Musical Instruments, Magnetic Levitation, Bullet Trains, Electric Motors, Radios, TV, Car Coils, Superconductivity, Aurora Borealis, Rainbows, Radio Telescopes, Interferometers, Particle Accelerators such as the Large Hadron Collider, Mass Spectrometers, Red Sunsets, Blue Skies, Haloes around Sun and Moon, Color Perception, Doppler Effect and Big-Bang Cosmology.

+ +

Professor Lewin received his PhD in Nuclear Physics at the Technical University in Delft, the Netherlands in 1965. He joined the Physics faculty at MIT in 1966 and became a pioneer in the new field of X-ray Astronomy. His 105 online lectures are world-renowned and are viewed by nearly 2 million people annually. Professor Lewin has received five teaching awards and is the only MIT professor featured in "The Best 300 Professors" by The Princeton Review. He has co-authored with Warren Goldstein the book "For the Love of Physics" (Free Press, Simon & Schuster), which has been translated into 9 languages.

+ +

Previously announced new 2013 courses include: Justice from Michael Sandel; Introduction to Statistics from Ani Adhikari; The Challenges of Global Poverty from Esther Duflo; The Ancient Greek Hero from Gregory Nagy; Quantum Mechanics and Quantum Computation from Umesh Vazirani; Human Health and Global Environmental Change, from Aaron Bernstein and Jack Spengler.

+ +

In addition to these new courses, edX is bringing back several courses from the popular fall 2012 semester: Introduction to Computer Science and Programming; Introduction to Solid State Chemistry; Introduction to Artificial Intelligence; Software as a Service I; Software as a Service II; Foundations of Computer Graphics.

+ +

About edX

+ +

EdX is a not-for-profit enterprise of its founding partners Harvard University and the Massachusetts Institute of Technology focused on transforming online and on-campus learning through groundbreaking methodologies, game-like experiences and cutting-edge research. EdX provides inspirational and transformative knowledge to students of all ages, social status, and income who form worldwide communities of learners. EdX uses its open source technology to transcend physical and social borders. We’re focused on people, not profit. EdX is based in Cambridge, Massachusetts in the USA.

+ +
+

Contact:

+

Brad Baker, Weber Shandwick for edX

+

BBaker@webershandwick.com

+

(617) 520-7043

+
+ + +
+
+
diff --git a/lms/templates/test_center_register.html b/lms/templates/test_center_register.html new file mode 100644 index 0000000000..f6c53c0e89 --- /dev/null +++ b/lms/templates/test_center_register.html @@ -0,0 +1,480 @@ +<%! + from django.core.urlresolvers import reverse + from courseware.courses import course_image_url, get_course_about_section + from courseware.access import has_access + from certificates.models import CertificateStatuses +%> +<%inherit file="main.html" /> + +<%namespace name='static' file='static_content.html'/> + +<%block name="title">Pearson VUE Test Center Proctoring - Registration + +<%block name="js_extra"> + + + +
+ +
+
+
+

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

+ + % if registration: +

Your Pearson VUE Proctored Exam Registration

+ % else: +

Register for a Pearson VUE Proctored Exam

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

Your registration for the Pearson exam has been processed

+

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

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

Your demographic information contained an error and was rejected

+

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

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

Your registration for the Pearson exam has been rejected

+

Please see your registration status details for more information.

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

Your registration for the Pearson exam is pending

+

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

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

Registration for this Pearson exam is closed

+

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

+ % endif + + % if registration: +

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

+ % else: +

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

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

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

+ % endif + % else: +

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

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

+
    +
    +
    + + + % if registration: + % if registration.accommodation_request and len(registration.accommodation_request) > 0: + + % endif + % else: + Special (ADA) Accommodations + % endif +
    + + +
    diff --git a/lms/urls.py b/lms/urls.py index ca342164a9..9d590387e4 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 @@ -115,9 +118,11 @@ urlpatterns = ('', {'template': 'press_releases/Georgetown_joins_edX.html'}, name="press/georgetown-joins-edx"), url(r'^press/spring-courses$', 'static_template_view.views.render', {'template': 'press_releases/Spring_2013_course_announcements.html'}, name="press/spring-courses"), + url(r'^press/lewin-course-announcement$', 'static_template_view.views.render', + {'template': 'press_releases/Lewin_course_announcement.html'}, name="press/lewin-course-announcement"), # Should this always update to point to the latest press release? - (r'^pressrelease$', 'django.views.generic.simple.redirect_to', {'url': '/press/spring-courses'}), + (r'^pressrelease$', 'django.views.generic.simple.redirect_to', {'url': '/press/lewin-course-announcement'}), (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}), diff --git a/requirements.txt b/requirements.txt index b5c9395fec..996388a51d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,10 +52,10 @@ pil==1.1.7 nltk==2.0.4 django-debug-toolbar-mongo dogstatsd-python==0.2.1 -# Taking out MySQL-python for now because it requires mysql to be installed, so breaks updates on content folks' envs. -# MySQL-python +MySQL-python==1.2.4c1 sphinx==1.1.3 factory_boy Shapely==1.2.16 ipython==0.13.1 +xmltodict==0.4.1