diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4ebbcff59201b6cf0d20de1b196f640c0b39742e..e5582e2f27912c8ef8eaf7599ce7a553667d6b0c 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -47,6 +47,8 @@ from student.models import ( ) from student.forms import PasswordResetFormNoActive +from verify_student.models import SoftwareSecurePhotoVerification + from certificates.models import CertificateStatuses, certificate_status_for_student from xmodule.course_module import CourseDescriptor @@ -334,6 +336,8 @@ def dashboard(request): CourseAuthorization.instructor_email_enabled(course.id) ) ) + # Verification Attempts + verification_status = SoftwareSecurePhotoVerification.user_status(user) # get info w.r.t ExternalAuthMap external_auth_map = None try: @@ -351,6 +355,8 @@ def dashboard(request): 'all_course_modes': course_modes, 'cert_statuses': cert_statuses, 'show_email_settings_for': show_email_settings_for, + 'verification_status': verification_status[0], + 'verification_msg': verification_status[1], } return render_to_response('dashboard.html', context) diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 39ea4106ff913b9774362230d67fe0e6dda61dd2..17cffa4ead891bc273af290370ac9ecba66a0b7e 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -26,6 +26,7 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.db import models from django.contrib.auth.models import User +from django.utils.translation import ugettext as _ from model_utils.models import StatusModel from model_utils import Choices @@ -174,6 +175,17 @@ class PhotoVerification(StatusModel): ordering = ['-created_at'] ##### Methods listed in the order you'd typically call them + @classmethod + def _earliest_allowed_date(cls): + """ + Returns the earliest allowed date given the settings + + """ + allowed_date = ( + datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR) + ) + return allowed_date + @classmethod def user_is_verified(cls, user, earliest_allowed_date=None): """ @@ -181,14 +193,11 @@ class PhotoVerification(StatusModel): identity. Depending on the policy, this can expire after some period of time, so a user might have to renew periodically. """ - earliest_allowed_date = ( - earliest_allowed_date or - datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR) - ) return cls.objects.filter( user=user, status="approved", - created_at__gte=earliest_allowed_date + created_at__gte=(earliest_allowed_date + or cls._earliest_allowed_date()) ).exists() @classmethod @@ -201,14 +210,11 @@ class PhotoVerification(StatusModel): on the contents of the attempt, and we have not yet received a denial. """ valid_statuses = ['must_retry', 'submitted', 'approved'] - earliest_allowed_date = ( - earliest_allowed_date or - datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR) - ) return cls.objects.filter( user=user, status__in=valid_statuses, - created_at__gte=earliest_allowed_date + created_at__gte=(earliest_allowed_date + or cls._earliest_allowed_date()) ).exists() @classmethod @@ -225,6 +231,38 @@ class PhotoVerification(StatusModel): else: return None + @classmethod + def user_status(cls, user): + """ + Returns the status of the user based on their latest verification attempt + + If no such verification exists, returns 'none' + If verification has expired, returns 'expired' + """ + try: + attempts = cls.objects.filter(user=user).order_by('-updated_at') + attempt = attempts[0] + except IndexError: + return ('none', '') + + if attempt.created_at < cls._earliest_allowed_date(): + return ('expired', '') + + error_msg = attempt.error_msg + if attempt.error_msg: + error_msg = attempt.parse_error_msg() + + return (attempt.status, error_msg) + + def parse_error_msg(self): + """ + Sometimes, the error message we've received needs to be parsed into + something more human readable + + The default behavior is to return the current error message as is. + """ + return self.error_msg + @status_before_must_be("created") def upload_face_image(self, img): raise NotImplementedError @@ -486,6 +524,37 @@ class SoftwareSecurePhotoVerification(PhotoVerification): self.status = "must_retry" self.save() + def parse_error_msg(self): + """ + Parse the error messages we receive from SoftwareSecure + + Error messages are written in the form: + + `[{"photoIdReasons": ["Not provided"]}]` + + Returns a list of error messages + """ + # Translates the category names into something more human readable + category_dict = { + "photoIdReasons": _("Photo ID Issues: "), + "generalReasons": u"" + } + + try: + msg_json = json.loads(self.error_msg) + msg_dict = msg_json[0] + + msg = [] + for category in msg_dict: + # translate the category into a human-readable name + category_name = category_dict[category] + msg.append(category_name + u", ".join(msg_dict[category])) + return u", ".join(msg) + except (ValueError, KeyError): + # if we can't parse the message as JSON or the category doesn't + # match one of our known categories, show a generic error + return _("There was an error verifying your ID photos.") + def image_url(self, name): """ We dynamically generate this, since we want it the expiration clock to diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index 40842cdcdb30837d98b248c258facee3f129934c..dcaa9cade9f126f9a1166150f5230548a9db355b 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -17,11 +17,11 @@ from util.testing import UrlResetMixin import verify_student.models FAKE_SETTINGS = { - "SOFTWARE_SECURE" : { + "SOFTWARE_SECURE": { "FACE_IMAGE_AES_KEY" : "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "API_ACCESS_KEY" : "BBBBBBBBBBBBBBBBBBBB", - "API_SECRET_KEY" : "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", - "RSA_PUBLIC_KEY" : """-----BEGIN PUBLIC KEY----- + "API_ACCESS_KEY": "BBBBBBBBBBBBBBBBBBBB", + "API_SECRET_KEY": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "RSA_PUBLIC_KEY": """-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu2fUn20ZQtDpa1TKeCA/ rDA2cEeFARjEr41AP6jqP/k3O7TeqFX6DgCBkxcjojRCs5IfE8TimBHtv/bcSx9o 7PANTq/62ZLM9xAMpfCcU6aAd4+CVqQkXSYjj5TUqamzDFBkp67US8IPmw7I2Gaa @@ -30,10 +30,10 @@ dyZCM9pBcvcH+60ma+nNg8GVGBAW/oLxILBtg+T3PuXSUvcu/r6lUFMHk55pU94d 9A/T8ySJm379qU24ligMEetPk1o9CUasdaI96xfXVDyFhrzrntAmdD+HYCSPOQHz iwIDAQAB -----END PUBLIC KEY-----""", - "API_URL" : "http://localhost/verify_student/fake_endpoint", - "AWS_ACCESS_KEY" : "FAKEACCESSKEY", - "AWS_SECRET_KEY" : "FAKESECRETKEY", - "S3_BUCKET" : "fake-bucket" + "API_URL": "http://localhost/verify_student/fake_endpoint", + "AWS_ACCESS_KEY": "FAKEACCESSKEY", + "AWS_SECRET_KEY": "FAKESECRETKEY", + "S3_BUCKET": "fake-bucket" } } @@ -57,11 +57,13 @@ class MockKey(object): def generate_url(self, duration): return "http://fake-edx-s3.edx.org/" + class MockBucket(object): """Mocking a boto S3 Bucket object.""" def __init__(self, name): self.name = name + class MockS3Connection(object): """Mocking a boto S3 Connection""" def __init__(self, access_key, secret_key): @@ -165,14 +167,14 @@ class TestPhotoVerification(TestCase): # approved assert_raises(VerificationException, attempt.submit) - attempt.approve() # no-op - attempt.system_error("System error") # no-op, something processed it without error + attempt.approve() # no-op + attempt.system_error("System error") # no-op, something processed it without error attempt.deny(DENY_ERROR_MSG) # denied assert_raises(VerificationException, attempt.submit) - attempt.deny(DENY_ERROR_MSG) # no-op - attempt.system_error("System error") # no-op, something processed it without error + attempt.deny(DENY_ERROR_MSG) # no-op + attempt.system_error("System error") # no-op, something processed it without error attempt.approve() def test_name_freezing(self): @@ -307,3 +309,49 @@ class TestPhotoVerification(TestCase): attempt.save() assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status) + def test_user_status(self): + # test for correct status when no error returned + user = UserFactory.create() + status = SoftwareSecurePhotoVerification.user_status(user) + self.assertEquals(status, ('none', '')) + + # test for when one has been created + attempt = SoftwareSecurePhotoVerification(user=user) + attempt.status = 'approved' + attempt.save() + + status = SoftwareSecurePhotoVerification.user_status(user) + self.assertEquals(status, (attempt.status, '')) + + # create another one for the same user, make sure the right one is + # returned + attempt2 = SoftwareSecurePhotoVerification(user=user) + attempt2.status = 'denied' + attempt2.error_msg = '[{"photoIdReasons": ["Not provided"]}]' + attempt2.save() + + status = SoftwareSecurePhotoVerification.user_status(user) + self.assertEquals(status, (attempt2.status, "Photo ID Issues: Not provided")) + + def test_parse_error_msg_success(self): + user = UserFactory.create() + attempt = SoftwareSecurePhotoVerification(user=user) + attempt.status = 'denied' + attempt.error_msg = '[{"photoIdReasons": ["Not provided"]}]' + parsed_error_msg = attempt.parse_error_msg() + self.assertEquals("Photo ID Issues: Not provided", parsed_error_msg) + + def test_parse_error_msg_failure(self): + user = UserFactory.create() + attempt = SoftwareSecurePhotoVerification(user=user) + attempt.status = 'denied' + # when we can't parse into json + bad_messages = { + 'Not Provided', + '[{"IdReasons": ["Not provided"]}]', + '{"IdReasons": ["Not provided"]}', + } + for msg in bad_messages: + attempt.error_msg = msg + parsed_error_msg = attempt.parse_error_msg() + self.assertEquals(parsed_error_msg, "There was an error verifying your ID photos.") diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index d4d3120dc9a1a77974ca12af34147de581e33ca9..43cbff15bd3bccdfae8b8aebcbee886b0db92e3e 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -14,6 +14,7 @@ from mock import patch, Mock, ANY from django.test import TestCase from django.test.utils import override_settings +from django.conf import settings from django.core.urlresolvers import reverse from django.core.exceptions import ObjectDoesNotExist @@ -98,6 +99,7 @@ class TestReverifyView(TestCase): self.assertIn('photo_reverification', template) self.assertTrue(context['error']) + @patch.dict(settings.MITX_FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) def test_reverify_post_success(self): url = reverse('verify_student_reverify') response = self.client.post(url, {'face_image': ',', diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index d6e81faf27175fc393d556a42b1313e66aa75232..76a2b819262fe81e85e8fafac439e026e2807c1e 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -98,6 +98,9 @@ MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True MITX_FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False +# Don't actually send any requests to Software Secure for student identity +# verification. +MITX_FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True # Configure the payment processor to use the fake processing page # Since both the fake payment page and the shoppingcart app are using diff --git a/lms/envs/test.py b/lms/envs/test.py index 8093d12f4594e682ab8308e74b2060649d5739d5..42a4d84436584bc8ad6c40dd204d4a2580be907c 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -36,10 +36,6 @@ MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True MITX_FEATURES['ENABLE_SHOPPING_CART'] = True -# Don't actually send any requests to Software Secure for student identity -# verification. -MITX_FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True - # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. WIKI_ENABLED = True