diff --git a/cms/xmodule_namespace.py b/cms/xmodule_namespace.py index c9bb8f4c6e..1b509a14f4 100644 --- a/cms/xmodule_namespace.py +++ b/cms/xmodule_namespace.py @@ -4,22 +4,8 @@ Namespace defining common fields used by Studio for all blocks import datetime -from xblock.core import Namespace, Boolean, Scope, ModelType, String - - -class StringyBoolean(Boolean): - """ - Reads strings from JSON as booleans. - - If the string is 'true' (case insensitive), then return True, - otherwise False. - - JSON values that aren't strings are returned as is - """ - def from_json(self, value): - if isinstance(value, basestring): - return value.lower() == 'true' - return value +from xblock.core import Namespace, Scope, ModelType, String +from xmodule.fields import StringyBoolean class DateTuple(ModelType): diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index effae923b3..23b46aa803 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -12,7 +12,7 @@ from external_auth.djangostore import DjangoOpenIDStore from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login from django.contrib.auth.models import User -from student.models import UserProfile +from student.models import UserProfile, TestCenterUser, TestCenterRegistration from django.http import HttpResponse, HttpResponseRedirect from django.utils.http import urlquote @@ -34,6 +34,12 @@ from openid.server.trustroot import TrustRoot from openid.extensions import ax, sreg import student.views as student_views +# Required for Pearson +from courseware.views import get_module_for_descriptor, jump_to +from courseware.model_data import ModelDataCache +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore import Location log = logging.getLogger("mitx.external_auth") @@ -551,7 +557,7 @@ def provider_login(request): 'nickname': user.username, 'email': user.email, 'fullname': user.username - } + } # the request succeeded: return provider_respond(server, openid_request, response, results) @@ -606,3 +612,140 @@ def provider_xrds(request): # custom XRDS header necessary for discovery process response['X-XRDS-Location'] = get_xrds_url('xrds', request) return response + + +#------------------- +# Pearson +#------------------- +def course_from_id(course_id): + """Return the CourseDescriptor corresponding to this course_id""" + course_loc = CourseDescriptor.id_to_location(course_id) + return modulestore().get_instance(course_id, course_loc) + + +@csrf_exempt +def test_center_login(request): + ''' Log in students taking exams via Pearson + + Takes a POST request that contains the following keys: + - code - a security code provided by Pearson + - clientCandidateID + - registrationID + - exitURL - the url that we redirect to once we're done + - vueExamSeriesCode - a code that indicates the exam that we're using + ''' + # errors are returned by navigating to the error_url, adding a query parameter named "code" + # which contains the error code describing the exceptional condition. + def makeErrorURL(error_url, error_code): + log.error("generating error URL with error code {}".format(error_code)) + return "{}?code={}".format(error_url, error_code) + + # get provided error URL, which will be used as a known prefix for returning error messages to the + # Pearson shell. + error_url = request.POST.get("errorURL") + + # TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson + # with the code we calculate for the same parameters. + if 'code' not in request.POST: + return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode")) + code = request.POST.get("code") + + # calculate SHA for query string + # TODO: figure out how to get the original query string, so we can hash it and compare. + + if 'clientCandidateID' not in request.POST: + return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID")) + client_candidate_id = request.POST.get("clientCandidateID") + + # TODO: check remaining parameters, and maybe at least log if they're not matching + # expected values.... + # registration_id = request.POST.get("registrationID") + # exit_url = request.POST.get("exitURL") + + # find testcenter_user that matches the provided ID: + try: + testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) + except TestCenterUser.DoesNotExist: + log.error("not able to find demographics for cand ID {}".format(client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID")) + + # find testcenter_registration that matches the provided exam code: + # Note that we could rely in future on either the registrationId or the exam code, + # or possibly both. But for now we know what to do with an ExamSeriesCode, + # while we currently have no record of RegistrationID values at all. + if 'vueExamSeriesCode' not in request.POST: + # we are not allowed to make up a new error code, according to Pearson, + # so instead of "missingExamSeriesCode", we use a valid one that is + # inaccurate but at least distinct. (Sigh.) + log.error("missing exam series code for cand ID {}".format(client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID")) + exam_series_code = request.POST.get('vueExamSeriesCode') + + registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code) + if not registrations: + log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned")) + + # TODO: figure out what to do if there are more than one registrations.... + # for now, just take the first... + registration = registrations[0] + + course_id = registration.course_id + course = course_from_id(course_id) # assume it will be found.... + if not course: + log.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) + exam = course.get_test_center_exam(exam_series_code) + if not exam: + log.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) + location = exam.exam_url + log.info("proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location)) + + # check if the test has already been taken + timelimit_descriptor = modulestore().get_instance(course_id, Location(location)) + if not timelimit_descriptor: + log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location)) + return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")) + + timelimit_module_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, + timelimit_descriptor, depth=None) + timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, + timelimit_module_cache, course_id, position=None) + if not timelimit_module.category == 'timelimit': + log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location)) + return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")) + + if timelimit_module and timelimit_module.has_ended: + log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) + return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")) + + # check if we need to provide an accommodation: + time_accommodation_mapping = {'ET12ET': 'ADDHALFTIME', + 'ET30MN': 'ADD30MIN', + 'ETDBTM': 'ADDDOUBLE', } + + time_accommodation_code = None + for code in registration.get_accommodation_codes(): + if code in time_accommodation_mapping: + time_accommodation_code = time_accommodation_mapping[code] + + if time_accommodation_code: + timelimit_module.accommodation_code = time_accommodation_code + log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) + + # UGLY HACK!!! + # Login assumes that authentication has occurred, and that there is a + # backend annotation on the user object, indicating which backend + # against which the user was authenticated. We're authenticating here + # against the registration entry, and assuming that the request given + # this information is correct, we allow the user to be logged in + # without a password. This could all be formalized in a backend object + # that does the above checking. + # TODO: (brian) create a backend class to do this. + # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) + testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") + login(request, testcenteruser.user) + + # And start the test: + return jump_to(request, course_id, location) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 98b5265d5f..abcb9d988b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1073,132 +1073,6 @@ def accept_name_change(request): return accept_name_change_by_id(int(request.POST['id'])) -@csrf_exempt -def test_center_login(request): - # errors are returned by navigating to the error_url, adding a query parameter named "code" - # which contains the error code describing the exceptional condition. - def makeErrorURL(error_url, error_code): - log.error("generating error URL with error code {}".format(error_code)) - return "{}?code={}".format(error_url, error_code) - - # get provided error URL, which will be used as a known prefix for returning error messages to the - # Pearson shell. - error_url = request.POST.get("errorURL") - - # TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson - # with the code we calculate for the same parameters. - if 'code' not in request.POST: - return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode")) - code = request.POST.get("code") - - # calculate SHA for query string - # TODO: figure out how to get the original query string, so we can hash it and compare. - - - if 'clientCandidateID' not in request.POST: - return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID")) - client_candidate_id = request.POST.get("clientCandidateID") - - # TODO: check remaining parameters, and maybe at least log if they're not matching - # expected values.... - # registration_id = request.POST.get("registrationID") - # exit_url = request.POST.get("exitURL") - - # find testcenter_user that matches the provided ID: - try: - testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) - except TestCenterUser.DoesNotExist: - log.error("not able to find demographics for cand ID {}".format(client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID")) - - # find testcenter_registration that matches the provided exam code: - # Note that we could rely in future on either the registrationId or the exam code, - # or possibly both. But for now we know what to do with an ExamSeriesCode, - # while we currently have no record of RegistrationID values at all. - if 'vueExamSeriesCode' not in request.POST: - # we are not allowed to make up a new error code, according to Pearson, - # so instead of "missingExamSeriesCode", we use a valid one that is - # inaccurate but at least distinct. (Sigh.) - log.error("missing exam series code for cand ID {}".format(client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID")) - exam_series_code = request.POST.get('vueExamSeriesCode') - # special case for supporting test user: - if client_candidate_id == "edX003671291147" and exam_series_code != '6002x001': - log.warning("test user {} using unexpected exam code {}, coercing to 6002x001".format(client_candidate_id, exam_series_code)) - exam_series_code = '6002x001' - - registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code) - if not registrations: - log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned")) - - # TODO: figure out what to do if there are more than one registrations.... - # for now, just take the first... - registration = registrations[0] - - course_id = registration.course_id - course = course_from_id(course_id) # assume it will be found.... - if not course: - log.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) - exam = course.get_test_center_exam(exam_series_code) - if not exam: - log.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) - location = exam.exam_url - log.info("proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location)) - - # check if the test has already been taken - timelimit_descriptor = modulestore().get_instance(course_id, Location(location)) - if not timelimit_descriptor: - log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")) - - timelimit_module_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, - timelimit_descriptor, depth=None) - timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, - timelimit_module_cache, course_id, position=None) - if not timelimit_module.category == 'timelimit': - log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")) - - if timelimit_module and timelimit_module.has_ended: - log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) - return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")) - - # check if we need to provide an accommodation: - time_accommodation_mapping = {'ET12ET' : 'ADDHALFTIME', - 'ET30MN' : 'ADD30MIN', - 'ETDBTM' : 'ADDDOUBLE', } - - time_accommodation_code = None - for code in registration.get_accommodation_codes(): - if code in time_accommodation_mapping: - time_accommodation_code = time_accommodation_mapping[code] - # special, hard-coded client ID used by Pearson shell for testing: - if client_candidate_id == "edX003671291147": - time_accommodation_code = 'TESTING' - - if time_accommodation_code: - timelimit_module.accommodation_code = time_accommodation_code - log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) - - # UGLY HACK!!! - # Login assumes that authentication has occurred, and that there is a - # backend annotation on the user object, indicating which backend - # against which the user was authenticated. We're authenticating here - # against the registration entry, and assuming that the request given - # this information is correct, we allow the user to be logged in - # without a password. This could all be formalized in a backend object - # that does the above checking. - # TODO: (brian) create a backend class to do this. - # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) - testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") - login(request, testcenteruser.user) - - # And start the test: - return jump_to(request, course_id, location) - def _get_news(top=None): "Return the n top news items on settings.RSS_URL" diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 9b0cc44ab4..4143345196 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -16,35 +16,13 @@ from .progress import Progress from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor from xmodule.exceptions import NotFoundError, ProcessingError -from xblock.core import Integer, Scope, String, Boolean, Object, Float -from .fields import Timedelta, Date +from xblock.core import Scope, String, Boolean, Object +from .fields import Timedelta, Date, StringyInteger, StringyFloat from xmodule.util.date_utils import time_to_datetime log = logging.getLogger("mitx.courseware") -class StringyInteger(Integer): - """ - A model type that converts from strings to integers when reading from json - """ - def from_json(self, value): - try: - return int(value) - except: - return None - - -class StringyFloat(Float): - """ - A model type that converts from string to floats when reading from json - """ - def from_json(self, value): - try: - return float(value) - except: - return None - - # Generated this many different variants of problems with rerandomize=per_student NUM_RANDOMIZATION_BINS = 20 @@ -95,7 +73,6 @@ class CapaFields(object): input_state = Object(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state) student_answers = Object(help="Dictionary with the current student responses", scope=Scope.user_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state) - display_name = String(help="Display name for this module", scope=Scope.settings) seed = StringyInteger(help="Random seed for this student", scope=Scope.user_state) weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings) markdown = String(help="Markdown source of this module", scope=Scope.settings) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 120e4f743a..239adcaa41 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -8,8 +8,7 @@ from .x_module import XModule from xblock.core import Integer, Scope, String, Boolean, List from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor from collections import namedtuple -from .fields import Date -from xmodule.open_ended_grading_classes.xblock_field_types import StringyFloat +from .fields import Date, StringyFloat log = logging.getLogger("mitx.courseware") diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index bb85714252..3d56b7941e 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -7,6 +7,8 @@ from xblock.core import ModelType import datetime import dateutil.parser +from xblock.core import Integer, Float, Boolean + log = logging.getLogger(__name__) @@ -81,3 +83,42 @@ class Timedelta(ModelType): if cur_value > 0: values.append("%d %s" % (cur_value, attr)) return ' '.join(values) + + +class StringyInteger(Integer): + """ + A model type that converts from strings to integers when reading from json. + If value does not parse as an int, returns None. + """ + def from_json(self, value): + try: + return int(value) + except: + return None + + +class StringyFloat(Float): + """ + A model type that converts from string to floats when reading from json. + If value does not parse as a float, returns None. + """ + def from_json(self, value): + try: + return float(value) + except: + return None + + +class StringyBoolean(Boolean): + """ + Reads strings from JSON as booleans. + + If the string is 'true' (case insensitive), then return True, + otherwise False. + + JSON values that aren't strings are returned as-is. + """ + def from_json(self, value): + if isinstance(value, basestring): + return value.lower() == 'true' + return value diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py deleted file mode 100644 index 2dcb7a4cda..0000000000 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/xblock_field_types.py +++ /dev/null @@ -1,14 +0,0 @@ -from xblock.core import Integer, Float - - -class StringyFloat(Float): - """ - A model type that converts from string to floats when reading from json - """ - - def from_json(self, value): - try: - return float(value) - except: - return None - diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index db4514d0e0..35f2fa2d76 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -11,8 +11,7 @@ from xmodule.raw_module import RawDescriptor from xmodule.modulestore.django import modulestore from .timeinfo import TimeInfo from xblock.core import Object, Integer, Boolean, String, Scope -from xmodule.open_ended_grading_classes.xblock_field_types import StringyFloat -from xmodule.fields import Date +from xmodule.fields import Date, StringyFloat from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService from open_ended_grading_classes import combined_open_ended_rubric diff --git a/common/lib/xmodule/xmodule/tests/test_fields.py b/common/lib/xmodule/xmodule/tests/test_fields.py index 7c8872efc1..9642f7c595 100644 --- a/common/lib/xmodule/xmodule/tests/test_fields.py +++ b/common/lib/xmodule/xmodule/tests/test_fields.py @@ -1,8 +1,8 @@ -"""Tests for Date class defined in fields.py.""" +"""Tests for classes defined in fields.py.""" import datetime import unittest from django.utils.timezone import UTC -from xmodule.fields import Date +from xmodule.fields import Date, StringyFloat, StringyInteger, StringyBoolean import time class DateTest(unittest.TestCase): @@ -78,3 +78,55 @@ class DateTest(unittest.TestCase): DateTest.date.from_json("2012-12-31T23:00:01-01:00")), "2013-01-01T00:00:01Z") + +class StringyIntegerTest(unittest.TestCase): + def assertEquals(self, expected, arg): + self.assertEqual(expected, StringyInteger().from_json(arg)) + + def test_integer(self): + self.assertEquals(5, '5') + self.assertEquals(0, '0') + self.assertEquals(-1023, '-1023') + + def test_none(self): + self.assertEquals(None, None) + self.assertEquals(None, 'abc') + self.assertEquals(None, '[1]') + self.assertEquals(None, '1.023') + + +class StringyFloatTest(unittest.TestCase): + + def assertEquals(self, expected, arg): + self.assertEqual(expected, StringyFloat().from_json(arg)) + + def test_float(self): + self.assertEquals(.23, '.23') + self.assertEquals(5, '5') + self.assertEquals(0, '0.0') + self.assertEquals(-1023.22, '-1023.22') + + def test_none(self): + self.assertEquals(None, None) + self.assertEquals(None, 'abc') + self.assertEquals(None, '[1]') + + +class StringyBooleanTest(unittest.TestCase): + + def assertEquals(self, expected, arg): + self.assertEqual(expected, StringyBoolean().from_json(arg)) + + def test_false(self): + self.assertEquals(False, "false") + self.assertEquals(False, "False") + self.assertEquals(False, "") + self.assertEquals(False, "hahahahah") + + def test_true(self): + self.assertEquals(True, "true") + self.assertEquals(True, "TruE") + + def test_pass_through(self): + self.assertEquals(123, 123) + diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 2343d24a57..f902a9665b 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -20,7 +20,6 @@ log = logging.getLogger(__name__) class VideoFields(object): data = String(help="XML data for the problem", scope=Scope.content) position = Integer(help="Current position in the video", scope=Scope.user_state, default=0) - display_name = String(help="Display name for this module", scope=Scope.settings) class VideoModule(VideoFields, XModule): diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 8363f744a0..0b03089774 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -224,9 +224,7 @@ FILE_UPLOAD_HANDLERS = ( PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) ########################## PEARSON TESTING ########################### -MITX_FEATURES['ENABLE_PEARSON_HACK_TEST'] = True -PEARSON_TEST_USER = "pearsontest" -PEARSON_TEST_PASSWORD = "12345" +MITX_FEATURES['ENABLE_PEARSON_LOGIN'] = False ########################## ANALYTICS TESTING ######################## diff --git a/lms/urls.py b/lms/urls.py index aac77a408a..082004c1be 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -32,12 +32,6 @@ urlpatterns = ('', url(r'^accept_name_change$', 'student.views.accept_name_change'), url(r'^reject_name_change$', 'student.views.reject_name_change'), url(r'^pending_name_changes$', 'student.views.pending_name_changes'), - - url(r'^testcenter/login$', 'student.views.test_center_login'), - - # url(r'^testcenter/login$', 'student.test_center_views.login'), - # url(r'^testcenter/logout$', 'student.test_center_views.logout'), - url(r'^event$', 'track.views.user_track'), url(r'^t/(?P