diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 84bb4f9b3a5d8ac5675a35a94f571a147760763e..2392a92d3a43986d48041d612581cc169c67664c 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -41,6 +41,7 @@ import json import logging import uuid from random import randint +from time import strftime from django.conf import settings @@ -236,6 +237,15 @@ class TestCenterUser(models.Model): testcenter_user.client_candidate_id = cand_id return testcenter_user + def is_accepted(self): + return self.upload_status == 'Accepted' + + def is_rejected(self): + return self.upload_status == 'Error' + + def is_pending(self): + return self.upload_status == '' + class TestCenterUserForm(ModelForm): class Meta: model = TestCenterUser @@ -373,15 +383,15 @@ class TestCenterRegistration(models.Model): @property def client_candidate_id(self): return self.testcenter_user.client_candidate_id - + @staticmethod - def create(testcenter_user, course_id, exam_info, accommodation_request): + def create(testcenter_user, exam, accommodation_request): registration = TestCenterRegistration(testcenter_user = testcenter_user) - registration.course_id = course_id + registration.course_id = exam.course_id registration.accommodation_request = accommodation_request - registration.exam_series_code = exam_info.get('Exam_Series_Code') - registration.eligibility_appointment_date_first = exam_info.get('First_Eligible_Appointment_Date') - registration.eligibility_appointment_date_last = exam_info.get('Last_Eligible_Appointment_Date') + registration.exam_series_code = exam.exam_series_code # .get('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) # accommodation_code remains blank for now, along with Pearson confirmation registration.client_authorization_id = registration._create_client_authorization_id() return registration @@ -404,16 +414,16 @@ class TestCenterRegistration(models.Model): return auth_id def is_accepted(self): - return self.upload_status == 'Accepted' + return self.upload_status == 'Accepted' and self.testcenter_user.is_accepted() def is_rejected(self): - return self.upload_status == 'Error' + return self.upload_status == 'Error' or self.testcenter_user.is_rejected() def is_pending_accommodation(self): return len(self.accommodation_request) > 0 and self.accommodation_code == '' def is_pending_acknowledgement(self): - return self.upload_status == '' and not self.is_pending_accommodation() + return (self.upload_status == '' or self.testcenter_user.is_pending()) and not self.is_pending_accommodation() class TestCenterRegistrationForm(ModelForm): class Meta: @@ -430,15 +440,12 @@ class TestCenterRegistrationForm(ModelForm): -def get_testcenter_registrations_for_user_and_course(user, course_id, exam_series_code=None): +def get_testcenter_registration(user, course_id, exam_series_code): try: tcu = TestCenterUser.objects.get(user=user) except TestCenterUser.DoesNotExist: return [] - if exam_series_code is None: - return TestCenterRegistration.objects.filter(testcenter_user=tcu, course_id=course_id) - else: - return TestCenterRegistration.objects.filter(testcenter_user=tcu, course_id=course_id, exam_series_code=exam_series_code) + return TestCenterRegistration.objects.filter(testcenter_user=tcu, course_id=course_id, exam_series_code=exam_series_code) def unique_id_for_user(user): """ diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 61a0d59c18a2c120c2e1922fb459b8d71e387821..f23ccd4668de79310ea057a7c93792afb65dee47 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -31,7 +31,7 @@ from student.models import (Registration, UserProfile, TestCenterUser, TestCente TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, - get_testcenter_registrations_for_user_and_course) + get_testcenter_registration) from certificates.models import CertificateStatuses, certificate_status_for_student @@ -237,6 +237,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) @@ -247,6 +249,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) @@ -589,32 +592,45 @@ 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 len(registrations) > 0: + registration = registrations[0] + else: + registration = None + return registration + @login_required @ensure_csrf_cookie def begin_test_registration(request, course_id): + """ Handles request to register the user for the current + test center exam of the specified course. Called by form + in dashboard.html. + """ user = request.user try: course = (course_from_id(course_id)) except ItemNotFoundError: + # TODO: do more than just log!! The rest will fail, so we should fail right now. log.error("User {0} enrolled in non-existent course {1}" .format(user.username, course_id)) # get the exam to be registered for: # (For now, we just assume there is one at most.) - # TODO: this should be an object, including the course_id and the - # exam info for a particular exam from the course. - exam_info = course.testcenter_info + exam_info = course.current_test_center_exam - # figure out if the user is already registered for this exam: - # (Again, for now we assume that any registration that exists is for this exam.) - registrations = get_testcenter_registrations_for_user_and_course(user, course_id) - if len(registrations) > 0: - registration = registrations[0] - else: - registration = None - - log.info("User {0} enrolled in course {1} calls for test registration page".format(user.username, course_id)) + # 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. @@ -636,12 +652,9 @@ def begin_test_registration(request, course_id): @ensure_csrf_cookie def create_test_registration(request, post_override=None): ''' - JSON call to create test registration. - Used by form in test_center_register.html, which is called from - into dashboard.html + JSON call to create a test center exam registration. + Called by form in test_center_register.html ''' - # js = {'success': False} - 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 @@ -660,7 +673,6 @@ def create_test_registration(request, post_override=None): testcenter_user = TestCenterUser.create(user) needs_updating = True - # perform validation: if needs_updating: log.info("User {0} enrolled in course {1} updating demographic info for test registration".format(user.username, course_id)) @@ -678,20 +690,19 @@ def create_test_registration(request, post_override=None): # create and save the registration: needs_saving = False - exam_info = course.testcenter_info - registrations = get_testcenter_registrations_for_user_and_course(user, course_id) - # In future, this should check the exam series code of the registrations, if there - # were multiple. + exam = course.current_test_center_exam + exam_code = exam.exam_series_code + registrations = get_testcenter_registration(user, course_id, exam_code) if len(registrations) > 0: registration = registrations[0] - # check to see if registration changed. Should check appointment dates too... + # TODO: check to see if registration changed. Should check appointment dates too... # And later should check changes in accommodation_code. # But at the moment, we don't expect anything to cause this to change - # right now. + # because of the registration form. else: accommodation_request = post_vars.get('accommodation_request','') - registration = TestCenterRegistration.create(testcenter_user, course_id, exam_info, accommodation_request) + registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request) needs_saving = True if needs_saving: @@ -732,8 +743,6 @@ def create_test_registration(request, post_override=None): # TODO: enable appropriate stat # statsd.increment("common.student.account_created") - log.info("User {0} enrolled in course {1} returning from enter/update demographic info for test registration".format(user.username, course_id)) - js = {'success': True} return HttpResponse(json.dumps(js), mimetype="application/json") diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 715b263b59c6c2100ff88091697455d35ff7e7f8..1b9da9fb068ff0608c4d81fb544f2fd0cc676eee 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -96,6 +96,21 @@ class CourseDescriptor(SequenceDescriptor): # disable the syllabus content for courses that do not provide a syllabus self.syllabus_present = self.system.resources_fs.exists(path('syllabus')) + self.test_center_exams = [] + test_center_info = self.metadata.get('testcenter_info') + if test_center_info is not None: + for exam_name in test_center_info: + try: + exam_info = test_center_info[exam_name] + self.test_center_exams.append(self.TestCenterExam(self.id, exam_name, exam_info)) + except Exception as err: + # If we can't parse the test center exam info, don't break + # the rest of the courseware. + msg = 'Error %s: Unable to load test-center exam info for exam "%s" of course "%s"' % (err, exam_name, self.id) + log.error(msg) + continue + + def set_grading_policy(self, policy_str): """Parse the policy specified in policy_str, and save it""" try: @@ -315,25 +330,88 @@ class CourseDescriptor(SequenceDescriptor): Returns None if no url specified. """ return self.metadata.get('end_of_course_survey_url') - - @property - def testcenter_info(self): - """ - Pull from policy. - - TODO: decide if we expect this entry to be a single test, or if multiple tests are possible - per course. - For now we expect this entry to be a single test. + class TestCenterExam: + def __init__(self, course_id, exam_name, exam_info): + self.course_id = course_id + self.exam_name = exam_name + self.exam_info = exam_info + self.exam_series_code = exam_info.get('Exam_Series_Code') or exam_name + self.display_name = exam_info.get('Exam_Display_Name') or self.exam_series_code + self.first_eligible_appointment_date = self._try_parse_time('First_Eligible_Appointment_Date') + if self.first_eligible_appointment_date is None: + raise ValueError("First appointment date must be specified") + # TODO: If defaulting the last appointment date, it should be the + # *end* of the same day, not the same time. It's going to be used as the + # end of the exam overall, so we don't want the exam to disappear too soon. + # It's also used optionally as the registration end date, so time matters there too. + self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date + if self.last_eligible_appointment_date is None: + raise ValueError("Last appointment date must be specified") + self.registration_start_date = self._try_parse_time('Registration_Start_Date') or time.gmtime(0) + self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date + # do validation within the exam info: + if self.registration_start_date > self.registration_end_date: + raise ValueError("Registration start date must be before registration end date") + if self.first_eligible_appointment_date > self.last_eligible_appointment_date: + raise ValueError("First appointment date must be before last appointment date") + if self.registration_end_date > self.last_eligible_appointment_date: + raise ValueError("Registration end date must be before last appointment date") + - Returns None if no testcenter info specified, or if no exam is included. - """ - info = self.metadata.get('testcenter_info') - if info is None or len(info) == 0: - return None; - else: - return info.values()[0] + def _try_parse_time(self, key): + """ + Parse an optional metadata key containing a time: if present, complain + if it doesn't parse. + Return None if not present or invalid. + """ + if key in self.exam_info: + try: + return parse_time(self.exam_info[key]) + except ValueError as e: + msg = "Exam {0} in course {1} loaded with a bad exam_info key '{2}': '{3}'".format(self.exam_name, self.course_id, self.exam_info[key], e) + log.warning(msg) + return None + + def has_started(self): + return time.gmtime() > self.first_eligible_appointment_date + + def has_ended(self): + return time.gmtime() > self.last_eligible_appointment_date + + def has_started_registration(self): + return time.gmtime() > self.registration_start_date + + def has_ended_registration(self): + return time.gmtime() > self.registration_end_date + + def is_registering(self): + now = time.gmtime() + return now >= self.registration_start_date and now <= self.registration_end_date + @property + def first_eligible_appointment_date_text(self): + return time.strftime("%b %d, %Y", self.first_eligible_appointment_date) + + @property + def last_eligible_appointment_date_text(self): + return time.strftime("%b %d, %Y", self.last_eligible_appointment_date) + + @property + def registration_end_date_text(self): + return time.strftime("%b %d, %Y", self.registration_end_date) + + @property + def current_test_center_exam(self): + exams = [exam for exam in self.test_center_exams if exam.has_started_registration() and not exam.has_ended()] + if len(exams) > 1: + # TODO: output some kind of warning. This should already be + # caught if we decide to do validation at load time. + return exams[0] + elif len(exams) == 1: + return exams[0] + else: + return None @property def title(self): diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 5db8a4cd2a84c400447124bad5a838f9876d8052..d5e5ddf29d69def142497064b2d75dd4358ff62c 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -3,7 +3,6 @@ from courseware.courses import course_image_url, get_course_about_section from courseware.access import has_access from certificates.models import CertificateStatuses - from student.models import get_testcenter_registrations_for_user_and_course %> <%inherit file="main.html" /> @@ -220,36 +219,45 @@ <h3><a href="${course_target}">${course.number} ${course.title}</a></h3> </hgroup> - <!-- TODO: need to add logic to select which of the following to display. Like certs? --> <% - testcenter_info = course.testcenter_info - testcenter_register_target = reverse('begin_test_registration', args=[course.id]) + testcenter_exam_info = course.current_test_center_exam + registration = exam_registrations.get(course.id) + testcenter_register_target = reverse('begin_test_registration', args=[course.id]) %> - % if testcenter_info is not None: + % if testcenter_exam_info is not None: - <!-- see if there is already a registration object - TODO: need to add logic for when registration can begin. --> - <% - registrations = get_testcenter_registrations_for_user_and_course(user, course.id) - %> - % if len(registrations) == 0: + % if registration is None and testcenter_exam_info.is_registering(): <div class="message message-status is-shown exam-register"> <a href="${testcenter_register_target}" class="exam-button" id="exam_register_button">Register for Pearson exam</a> <p class="message-copy">Registration for the Pearson exam is now open.</p> </div> - % else: - <div class="message message-status is-shown"> - <p class="message-copy">Your - <a href="${testcenter_register_target}" id="exam_register_link">registration for the Pearson exam</a> - is pending. Within a few days, you should see a confirmation number here, which can be used to schedule your exam.</p> - </div> - + % endif + <!-- display a registration for a current exam, even if the registration period is over --> + % if registration is not None: + % if registration.is_accepted(): <div class="message message-status is-shown exam-schedule"> <!-- TODO: pull Pearson destination out into a Setting --> <a href="https://www1.pearsonvue.com/testtaker/signin/SignInPage/EDX" class="exam-button">Schedule Pearson exam</a> - <p class="exam-registration-number"><a href="${testcenter_register_target}" id="exam_register_link">Registration</a> number: <strong>${registrations[0].client_authorization_id}</strong></p> + <p class="exam-registration-number"><a href="${testcenter_register_target}" id="exam_register_link">Registration</a> number: <strong>${registration.client_authorization_id}</strong></p> <p class="message-copy">Write this down! You’ll need it to schedule your exam.</p> </div> + % endif + % if registration.is_rejected(): + <!-- TODO: revise rejection text --> + <div class="message message-status is-shown exam-schedule"> + <p class="message-copy">Your + <a href="${testcenter_register_target}" id="exam_register_link">registration for the Pearson exam</a> + has been rejected. Please check the information you provided, and try to correct any demographic errors. Otherwise + contact someone at edX or Pearson, or just scream for help.</p> + </div> + % endif + % if not registration.is_accepted() and not registration.is_rejected(): + <div class="message message-status is-shown"> + <p class="message-copy">Your + <a href="${testcenter_register_target}" id="exam_register_link">registration for the Pearson exam</a> + is pending. Within a few days, you should see a confirmation number here, which can be used to schedule your exam.</p> + </div> + % endif % endif % endif diff --git a/lms/templates/test_center_register.html b/lms/templates/test_center_register.html index 7b80ebc46bd9aaf9d8d94c0390e1832de9a9ef3f..2b0b0c4eb2033b162d315fb0e514a4c917094c06 100644 --- a/lms/templates/test_center_register.html +++ b/lms/templates/test_center_register.html @@ -3,7 +3,6 @@ from courseware.courses import course_image_url, get_course_about_section from courseware.access import has_access from certificates.models import CertificateStatuses - from student.models import get_testcenter_registrations_for_user_and_course %> <%inherit file="main.html" /> @@ -91,12 +90,15 @@ <section class="status"> <!-- NOTE: BT - registration data updated confirmation message - in case upon successful submit we're directing - folks back to this view. To display, add "is-shown" class to div --> + folks back to this view. To display, add "is-shown" class to div. + NOTE: BW - not planning to do this, but instead returning user to student dashboard. <div class="message message-status submission-saved"> <p class="message-copy">Your registration data has been updated and saved.</p> </div> + --> - <!-- NOTE: BT - Sample markup for error message. To display, add "is-shown" class to div --> + <!-- Markup for error message will be written here by ajax handler. To display, it adds "is-shown" class to div, + and adds specific error messages under the list. --> <div class="message message-status submission-error"> <p id="submission-error-heading" class="message-copy"></p> <ul id="submission-error-list"/> @@ -123,6 +125,7 @@ <input id="id_email" type="hidden" name="email" maxlength="75" value="${user.email}" /> <input id="id_username" type="hidden" name="username" maxlength="75" value="${user.username}" /> <input id="id_course_id" type="hidden" name="course_id" maxlength="75" value="${course.id}" /> + <input id="id_course_id" type="hidden" name="exam_series_code" maxlength="75" value="${exam_info.exam_series_code}" /> <div class="form-fields-primary"> <fieldset class="group group-form group-form-personalinformation"> @@ -240,14 +243,17 @@ <!-- Only display an accommodation request if one had been specified at registration time. So only prompt for an accommodation request if no registration exists. - BW: Bug. It is not enough to set the value of the disabled control. It does - not display any text. Perhaps we can use a different control. --> + BW to BT: It is not enough to set the value of the disabled control. It does + not display any text. Perhaps we can use a different markup instead of a control. --> <ol class="list-input"> % if registration: % if registration.accommodation_request and len(registration.accommodation_request) > 0: <li class="field disabled"> <label for="accommodations">Accommodations Requested</label> + <!-- <textarea class="long" id="accommodations" value="${registration.accommodation_request}" placeholder="" disabled="disabled"></textarea> + --> + <p id="accommodations">${registration.accommodation_request}</p> </li> % endif % else: @@ -274,30 +280,30 @@ % if registration: <h3 class="is-hidden">Registration Details</h3> - <!-- NOTE: BT - state for if registration is accepted --> % if registration.is_accepted(): - <% regstatus = "Registration approved by Pearson" %> <div class="message message-status registration-accepted is-shown"> + <% regstatus = "Registration approved by Pearson" %> <p class="registration-status"><span class="label">Registration Status: </span><span class="value">${regstatus}</span></p> <p class="registration-number"><span class="label">Registration number: </span> <span class="value">${registration.client_authorization_id}</span></p> <p class="message-copy">Write this down! You’ll need it to schedule your exam.</p> + <!-- TODO: pull this link out into some settable parameter. --> <a href="https://www1.pearsonvue.com/testtaker/signin/SignInPage/EDX" class="button exam-button">Schedule Pearson exam</a> </div> % endif % if registration.is_rejected(): - <!-- NOTE: BT - state for if registration is rejected --> - <% regstatus = "Registration rejected by Pearson: %s" % registration.upload_error_message %> + <!-- TODO: the registration may be failed because of the upload of the demographics or of the upload of the registration. + Fix this so that the correct upload error message is displayed. --> <div class="message message-status registration-rejected is-shown"> + <% regstatus = "Registration rejected by Pearson: %s" % registration.upload_error_message %> <p class="registration-status"><span class="label">Registration Status: </span><span class="value">${regstatus}</span></p> <p class="message-copy">Your registration for the Pearson exam has been rejected. Please contact Pearson VUE for further information regarding your registration.</p> </div> % endif % if registration.is_pending_accommodation(): - <% regstatus = "Registration pending approval of accommodation request" %> - <!-- NOTE: BT - state for if registration is pending --> <div class="message message-status registration-pending is-shown"> + <% regstatus = "Registration pending approval of accommodation request" %> <p class="registration-status"><span class="label">Registration Status: </span><span class="value">${regstatus}</span></p> <p class="message-copy">Your registration for the Pearson exam is pending. Within a few days, you should see confirmation here of granted accommodations. At that point, your registration will be forwarded to Pearson.</p> @@ -305,9 +311,8 @@ % endif % if registration.is_pending_acknowledgement(): - <% regstatus = "Registration pending acknowledgement by Pearson" %> - <!-- NOTE: BT - state for if registration is pending --> <div class="message message-status registration-pending is-shown"> + <% regstatus = "Registration pending acknowledgement by Pearson" %> <p class="registration-status"><span class="label">Registration Status: </span><span class="value">${regstatus}</span></p> <p class="message-copy">Your registration for the Pearson exam is pending. Within a few days, you should see a confirmation number here, which can be used to schedule your exam.</p> </div> @@ -333,16 +338,18 @@ <!-- NOTE: showing test details --> <h4>Pearson VUE Test Details</h4> % if exam_info is not None: - <!-- TODO: BT - Can we obtain a more human readable value for test-type (e.g. "Final Exam" or "Midterm Exam")? --> <ul> <li> - <span class="label">Exam Series Code:</span> <span class="value">${exam_info.get('Exam_Series_Code')}</span> + <span class="label">Exam Name:</span> <span class="value">${exam_info.display_name}</span> </li> <li> - <span class="label">First Eligible Appointment Date:</span> <span class="value">${exam_info.get('First_Eligible_Appointment_Date')}</span> + <span class="label">First Eligible Appointment Date:</span> <span class="value">${exam_info.first_eligible_appointment_date_text}</span> </li> <li> - <span class="label">Last Eligible Appointment Date:</span> <span class="value">${exam_info.get('Last_Eligible_Appointment_Date')}</span> + <span class="label">Last Eligible Appointment Date:</span> <span class="value">${exam_info.last_eligible_appointment_date_text}</span> + </li> + <li> + <span class="label">Registration End Date:</span> <span class="value">${exam_info.registration_end_date_text}</span> </li> </ul> % endif