From d3a4747b060faf8b3187473f3eb9d79d717473a8 Mon Sep 17 00:00:00 2001
From: Diana Huang <dkh@edx.org>
Date: Fri, 4 Oct 2013 15:54:50 -0400
Subject: [PATCH] Add new user_status functionality to PhotoVerification.

---
 common/djangoapps/student/views.py            |  6 ++
 lms/djangoapps/verify_student/models.py       | 89 ++++++++++++++++---
 .../verify_student/tests/test_models.py       | 72 ++++++++++++---
 .../verify_student/tests/test_views.py        |  2 +
 lms/envs/acceptance.py                        |  3 +
 lms/envs/test.py                              |  4 -
 6 files changed, 150 insertions(+), 26 deletions(-)

diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 4ebbcff5920..e5582e2f279 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 39ea4106ff9..17cffa4ead8 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 40842cdcdb3..dcaa9cade9f 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 d4d3120dc9a..43cbff15bd3 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 d6e81faf271..76a2b819262 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 8093d12f459..42a4d844365 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
 
-- 
GitLab