diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 84bb4f9b3a..2392a92d3a 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 61a0d59c18..f23ccd4668 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 715b263b59..1b9da9fb06 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,26 +330,89 @@ 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. - - 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] + 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") + def _try_parse_time(self, key): + """ + Parse an optional metadata key containing a time: if present, complain + if it doesn't parse. + Return None if not present or invalid. + """ + if key in self.exam_info: + try: + return parse_time(self.exam_info[key]) + except ValueError as e: + msg = "Exam {0} in course {1} loaded with a bad exam_info key '{2}': '{3}'".format(self.exam_name, self.course_id, self.exam_info[key], e) + log.warning(msg) + return None + + def has_started(self): + return time.gmtime() > self.first_eligible_appointment_date + + def has_ended(self): + return time.gmtime() > self.last_eligible_appointment_date + + def has_started_registration(self): + return time.gmtime() > self.registration_start_date + + def has_ended_registration(self): + return time.gmtime() > self.registration_end_date + + def is_registering(self): + now = time.gmtime() + return now >= self.registration_start_date and now <= self.registration_end_date + + @property + def first_eligible_appointment_date_text(self): + return time.strftime("%b %d, %Y", self.first_eligible_appointment_date) + + @property + def last_eligible_appointment_date_text(self): + return time.strftime("%b %d, %Y", self.last_eligible_appointment_date) + + @property + def registration_end_date_text(self): + return time.strftime("%b %d, %Y", self.registration_end_date) + + @property + def current_test_center_exam(self): + exams = [exam for exam in self.test_center_exams if exam.has_started_registration() and not exam.has_ended()] + if len(exams) > 1: + # TODO: output some kind of warning. This should already be + # caught if we decide to do validation at load time. + return exams[0] + elif len(exams) == 1: + return exams[0] + else: + return None + @property def title(self): return self.display_name diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 5db8a4cd2a..d5e5ddf29d 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 @@