Skip to content
Snippets Groups Projects
Commit d3a4747b authored by Diana Huang's avatar Diana Huang
Browse files

Add new user_status functionality to PhotoVerification.

parent f3149651
No related merge requests found
...@@ -47,6 +47,8 @@ from student.models import ( ...@@ -47,6 +47,8 @@ from student.models import (
) )
from student.forms import PasswordResetFormNoActive from student.forms import PasswordResetFormNoActive
from verify_student.models import SoftwareSecurePhotoVerification
from certificates.models import CertificateStatuses, certificate_status_for_student from certificates.models import CertificateStatuses, certificate_status_for_student
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
...@@ -334,6 +336,8 @@ def dashboard(request): ...@@ -334,6 +336,8 @@ def dashboard(request):
CourseAuthorization.instructor_email_enabled(course.id) CourseAuthorization.instructor_email_enabled(course.id)
) )
) )
# Verification Attempts
verification_status = SoftwareSecurePhotoVerification.user_status(user)
# get info w.r.t ExternalAuthMap # get info w.r.t ExternalAuthMap
external_auth_map = None external_auth_map = None
try: try:
...@@ -351,6 +355,8 @@ def dashboard(request): ...@@ -351,6 +355,8 @@ def dashboard(request):
'all_course_modes': course_modes, 'all_course_modes': course_modes,
'cert_statuses': cert_statuses, 'cert_statuses': cert_statuses,
'show_email_settings_for': show_email_settings_for, '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) return render_to_response('dashboard.html', context)
......
...@@ -26,6 +26,7 @@ from django.conf import settings ...@@ -26,6 +26,7 @@ from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import ugettext as _
from model_utils.models import StatusModel from model_utils.models import StatusModel
from model_utils import Choices from model_utils import Choices
...@@ -174,6 +175,17 @@ class PhotoVerification(StatusModel): ...@@ -174,6 +175,17 @@ class PhotoVerification(StatusModel):
ordering = ['-created_at'] ordering = ['-created_at']
##### Methods listed in the order you'd typically call them ##### 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 @classmethod
def user_is_verified(cls, user, earliest_allowed_date=None): def user_is_verified(cls, user, earliest_allowed_date=None):
""" """
...@@ -181,14 +193,11 @@ class PhotoVerification(StatusModel): ...@@ -181,14 +193,11 @@ class PhotoVerification(StatusModel):
identity. Depending on the policy, this can expire after some period of identity. Depending on the policy, this can expire after some period of
time, so a user might have to renew periodically. 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( return cls.objects.filter(
user=user, user=user,
status="approved", status="approved",
created_at__gte=earliest_allowed_date created_at__gte=(earliest_allowed_date
or cls._earliest_allowed_date())
).exists() ).exists()
@classmethod @classmethod
...@@ -201,14 +210,11 @@ class PhotoVerification(StatusModel): ...@@ -201,14 +210,11 @@ class PhotoVerification(StatusModel):
on the contents of the attempt, and we have not yet received a denial. on the contents of the attempt, and we have not yet received a denial.
""" """
valid_statuses = ['must_retry', 'submitted', 'approved'] 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( return cls.objects.filter(
user=user, user=user,
status__in=valid_statuses, status__in=valid_statuses,
created_at__gte=earliest_allowed_date created_at__gte=(earliest_allowed_date
or cls._earliest_allowed_date())
).exists() ).exists()
@classmethod @classmethod
...@@ -225,6 +231,38 @@ class PhotoVerification(StatusModel): ...@@ -225,6 +231,38 @@ class PhotoVerification(StatusModel):
else: else:
return None 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") @status_before_must_be("created")
def upload_face_image(self, img): def upload_face_image(self, img):
raise NotImplementedError raise NotImplementedError
...@@ -486,6 +524,37 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -486,6 +524,37 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
self.status = "must_retry" self.status = "must_retry"
self.save() 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): def image_url(self, name):
""" """
We dynamically generate this, since we want it the expiration clock to We dynamically generate this, since we want it the expiration clock to
......
...@@ -17,11 +17,11 @@ from util.testing import UrlResetMixin ...@@ -17,11 +17,11 @@ from util.testing import UrlResetMixin
import verify_student.models import verify_student.models
FAKE_SETTINGS = { FAKE_SETTINGS = {
"SOFTWARE_SECURE" : { "SOFTWARE_SECURE": {
"FACE_IMAGE_AES_KEY" : "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "FACE_IMAGE_AES_KEY" : "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"API_ACCESS_KEY" : "BBBBBBBBBBBBBBBBBBBB", "API_ACCESS_KEY": "BBBBBBBBBBBBBBBBBBBB",
"API_SECRET_KEY" : "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "API_SECRET_KEY": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
"RSA_PUBLIC_KEY" : """-----BEGIN PUBLIC KEY----- "RSA_PUBLIC_KEY": """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu2fUn20ZQtDpa1TKeCA/ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu2fUn20ZQtDpa1TKeCA/
rDA2cEeFARjEr41AP6jqP/k3O7TeqFX6DgCBkxcjojRCs5IfE8TimBHtv/bcSx9o rDA2cEeFARjEr41AP6jqP/k3O7TeqFX6DgCBkxcjojRCs5IfE8TimBHtv/bcSx9o
7PANTq/62ZLM9xAMpfCcU6aAd4+CVqQkXSYjj5TUqamzDFBkp67US8IPmw7I2Gaa 7PANTq/62ZLM9xAMpfCcU6aAd4+CVqQkXSYjj5TUqamzDFBkp67US8IPmw7I2Gaa
...@@ -30,10 +30,10 @@ dyZCM9pBcvcH+60ma+nNg8GVGBAW/oLxILBtg+T3PuXSUvcu/r6lUFMHk55pU94d ...@@ -30,10 +30,10 @@ dyZCM9pBcvcH+60ma+nNg8GVGBAW/oLxILBtg+T3PuXSUvcu/r6lUFMHk55pU94d
9A/T8ySJm379qU24ligMEetPk1o9CUasdaI96xfXVDyFhrzrntAmdD+HYCSPOQHz 9A/T8ySJm379qU24ligMEetPk1o9CUasdaI96xfXVDyFhrzrntAmdD+HYCSPOQHz
iwIDAQAB iwIDAQAB
-----END PUBLIC KEY-----""", -----END PUBLIC KEY-----""",
"API_URL" : "http://localhost/verify_student/fake_endpoint", "API_URL": "http://localhost/verify_student/fake_endpoint",
"AWS_ACCESS_KEY" : "FAKEACCESSKEY", "AWS_ACCESS_KEY": "FAKEACCESSKEY",
"AWS_SECRET_KEY" : "FAKESECRETKEY", "AWS_SECRET_KEY": "FAKESECRETKEY",
"S3_BUCKET" : "fake-bucket" "S3_BUCKET": "fake-bucket"
} }
} }
...@@ -57,11 +57,13 @@ class MockKey(object): ...@@ -57,11 +57,13 @@ class MockKey(object):
def generate_url(self, duration): def generate_url(self, duration):
return "http://fake-edx-s3.edx.org/" return "http://fake-edx-s3.edx.org/"
class MockBucket(object): class MockBucket(object):
"""Mocking a boto S3 Bucket object.""" """Mocking a boto S3 Bucket object."""
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
class MockS3Connection(object): class MockS3Connection(object):
"""Mocking a boto S3 Connection""" """Mocking a boto S3 Connection"""
def __init__(self, access_key, secret_key): def __init__(self, access_key, secret_key):
...@@ -165,14 +167,14 @@ class TestPhotoVerification(TestCase): ...@@ -165,14 +167,14 @@ class TestPhotoVerification(TestCase):
# approved # approved
assert_raises(VerificationException, attempt.submit) assert_raises(VerificationException, attempt.submit)
attempt.approve() # no-op attempt.approve() # no-op
attempt.system_error("System error") # no-op, something processed it without error attempt.system_error("System error") # no-op, something processed it without error
attempt.deny(DENY_ERROR_MSG) attempt.deny(DENY_ERROR_MSG)
# denied # denied
assert_raises(VerificationException, attempt.submit) assert_raises(VerificationException, attempt.submit)
attempt.deny(DENY_ERROR_MSG) # no-op attempt.deny(DENY_ERROR_MSG) # no-op
attempt.system_error("System error") # no-op, something processed it without error attempt.system_error("System error") # no-op, something processed it without error
attempt.approve() attempt.approve()
def test_name_freezing(self): def test_name_freezing(self):
...@@ -307,3 +309,49 @@ class TestPhotoVerification(TestCase): ...@@ -307,3 +309,49 @@ class TestPhotoVerification(TestCase):
attempt.save() attempt.save()
assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status) 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.")
...@@ -14,6 +14,7 @@ from mock import patch, Mock, ANY ...@@ -14,6 +14,7 @@ from mock import patch, Mock, ANY
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
...@@ -98,6 +99,7 @@ class TestReverifyView(TestCase): ...@@ -98,6 +99,7 @@ class TestReverifyView(TestCase):
self.assertIn('photo_reverification', template) self.assertIn('photo_reverification', template)
self.assertTrue(context['error']) self.assertTrue(context['error'])
@patch.dict(settings.MITX_FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_reverify_post_success(self): def test_reverify_post_success(self):
url = reverse('verify_student_reverify') url = reverse('verify_student_reverify')
response = self.client.post(url, {'face_image': ',', response = self.client.post(url, {'face_image': ',',
......
...@@ -98,6 +98,9 @@ MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True ...@@ -98,6 +98,9 @@ MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
MITX_FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False 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 # Configure the payment processor to use the fake processing page
# Since both the fake payment page and the shoppingcart app are using # Since both the fake payment page and the shoppingcart app are using
......
...@@ -36,10 +36,6 @@ MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True ...@@ -36,10 +36,6 @@ MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
MITX_FEATURES['ENABLE_SHOPPING_CART'] = 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. # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True WIKI_ENABLED = True
......
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment