Commit 4db1753e by Awais Qureshi

Merge pull request #7530 from edx/aamir-khan/ECOM-912-in-course-reverification

Aamir khan/ecom 912 in course reverification
parents 1e7dd064 1d8b0e3e
from ratelimitbackend import admin
from verify_student.models import SoftwareSecurePhotoVerification
from verify_student.models import SoftwareSecurePhotoVerification, InCourseReverificationConfiguration
admin.site.register(SoftwareSecurePhotoVerification)
admin.site.register(InCourseReverificationConfiguration)
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'InCourseReverificationConfiguration'
db.create_table('verify_student_incoursereverificationconfiguration', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
))
db.send_create_signal('verify_student', ['InCourseReverificationConfiguration'])
def backwards(self, orm):
# Deleting model 'InCourseReverificationConfiguration'
db.delete_table('verify_student_incoursereverificationconfiguration')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'reverification.midcoursereverificationwindow': {
'Meta': {'object_name': 'MidcourseReverificationWindow'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'end_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'start_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'})
},
'verify_student.incoursereverificationconfiguration': {
'Meta': {'object_name': 'InCourseReverificationConfiguration'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'verify_student.softwaresecurephotoverification': {
'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'},
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'display': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'db_index': 'True'}),
'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}),
'receipt_id': ('django.db.models.fields.CharField', [], {'default': "'8a3c9d8a-b885-480e-8e1e-ca111326db42'", 'max_length': '255', 'db_index': 'True'}),
'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}),
'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}),
'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}),
'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'window': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['reverification.MidcourseReverificationWindow']", 'null': 'True'})
},
'verify_student.verificationcheckpoint': {
'Meta': {'unique_together': "(('course_id', 'checkpoint_name'),)", 'object_name': 'VerificationCheckpoint'},
'checkpoint_name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'photo_verification': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['verify_student.SoftwareSecurePhotoVerification']", 'symmetrical': 'False'})
},
'verify_student.verificationstatus': {
'Meta': {'object_name': 'VerificationStatus'},
'checkpoint': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['verify_student.VerificationCheckpoint']"}),
'error': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'response': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['verify_student']
......@@ -35,8 +35,11 @@ from verify_student.ssencrypt import (
from reverification.models import MidcourseReverificationWindow
from xmodule_django.models import CourseKeyField
log = logging.getLogger(__name__)
from config_models.models import ConfigurationModel
def generateUUID(): # pylint: disable=invalid-name
""" Utility function; generates UUIDs """
......@@ -640,6 +643,17 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
query = cls.objects.filter(user=user, window=None).order_by('-updated_at')
return query[0]
@classmethod
def get_initial_verification(cls, user):
"""Get initial verification for a user
Arguments:
user(User): user object
Return:
SoftwareSecurePhotoVerification (object)
"""
init_verification = cls.objects.filter(user=user, status__in=["submitted", "approved"], window=None)
return init_verification.latest('created_at') if init_verification.exists() else None
@status_before_must_be("created")
def upload_face_image(self, img_data):
"""
......@@ -881,3 +895,131 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
log.debug("Return message:\n\n{}\n\n".format(response.text))
return response
@classmethod
def submit_faceimage(cls, user, face_image, photo_id_key):
"""Submit the faceimage to SoftwareSecurePhotoVerification
Arguments:
user(User): user object
face_image (bytestream): raw bytestream image data
photo_id_key (str) : SoftwareSecurePhotoVerification attribute
Returns:
SoftwareSecurePhotoVerification Object
"""
b64_face_image = face_image.split(",")[1]
attempt = SoftwareSecurePhotoVerification(user=user)
attempt.upload_face_image(b64_face_image.decode('base64'))
attempt.photo_id_key = photo_id_key
attempt.mark_ready()
attempt.save()
attempt.submit()
return attempt
class VerificationCheckpoint(models.Model):
"""Represents a point at which a user is challenged to reverify his or her identity.
Each checkpoint is uniquely identified by a (course_id, checkpoint_name) tuple.
"""
CHECKPOINT_CHOICES = (
("midterm", "midterm"),
("final", "final"),
)
course_id = CourseKeyField(max_length=255, db_index=True)
checkpoint_name = models.CharField(max_length=32, choices=CHECKPOINT_CHOICES)
photo_verification = models.ManyToManyField(SoftwareSecurePhotoVerification)
class Meta: # pylint: disable=missing-docstring, old-style-class
unique_together = (('course_id', 'checkpoint_name'),)
def add_verification_attempt(self, verification_attempt):
""" Add the verification attempt in M2M relation of photo_verification
Arguments:
verification_attempt(SoftwareSecurePhotoVerification): SoftwareSecurePhotoVerification object
Returns:
None
"""
self.photo_verification.add(verification_attempt) # pylint: disable=no-member
@classmethod
def get_verification_checkpoint(cls, course_id, checkpoint_name):
"""Get the verification checkpoint for given course_id and checkpoint name
Arguments:
course_id(CourseKey): CourseKey
checkpoint_name(str): checkpoint name
Returns:
VerificationCheckpoint object if exists otherwise None
"""
try:
return cls.objects.get(course_id=course_id, checkpoint_name=checkpoint_name)
except cls.DoesNotExist:
return None
class VerificationStatus(models.Model):
"""A verification status represents a user’s progress
through the verification process for a particular checkpoint
Model is an append-only table that represents the user status changes in
verification process
"""
VERIFICATION_STATUS_CHOICES = (
("submitted", "submitted"),
("approved", "approved"),
("denied", "denied"),
("error", "error")
)
checkpoint = models.ForeignKey(VerificationCheckpoint)
user = models.ForeignKey(User)
status = models.CharField(choices=VERIFICATION_STATUS_CHOICES, db_index=True, max_length=32)
timestamp = models.DateTimeField(auto_now_add=True)
response = models.TextField(null=True, blank=True)
error = models.TextField(null=True, blank=True)
@classmethod
def add_verification_status(cls, checkpoint, user, status):
""" Create new verification status object
Arguments:
checkpoint(VerificationCheckpoint): VerificationCheckpoint object
user(User): user object
status(str): String representing the status from VERIFICATION_STATUS_CHOICES
Returns:
None
"""
cls.objects.create(checkpoint=checkpoint, user=user, status=status)
@classmethod
def add_status_from_checkpoints(cls, checkpoints, user, status):
""" Create new verification status objects against the given checkpoints
Arguments:
checkpoints(list): list of VerificationCheckpoint objects
user(User): user object
status(str): String representing the status from VERIFICATION_STATUS_CHOICES
Returns:
None
"""
for checkpoint in checkpoints:
cls.objects.create(checkpoint=checkpoint, user=user, status=status)
class InCourseReverificationConfiguration(ConfigurationModel):
"""Configure in-course re-verification.
Enable or disable in-course re-verification feature.
When this flag is disabled, the "in-course re-verification" feature
will be disabled.
When the flag is enabled, the "in-course re-verification" feature
will be enabled.
"""
pass
# -*- coding: utf-8 -*-
from datetime import timedelta, datetime
import ddt
import json
import requests.exceptions
import pytz
from django.conf import settings
from django.test import TestCase
from django.db.utils import IntegrityError
from mock import patch
from nose.tools import assert_is_none, assert_equals, assert_raises, assert_true, assert_false # pylint: disable=E0611
from opaque_keys.edx.locations import SlashSeparatedCourseKey
......@@ -16,7 +18,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from verify_student.models import (
SoftwareSecurePhotoVerification, VerificationException,
SoftwareSecurePhotoVerification, VerificationException, VerificationCheckpoint, VerificationStatus
)
FAKE_SETTINGS = {
......@@ -599,3 +601,111 @@ class TestMidcourseReverification(ModuleStoreTestCase):
attempt.status = "approved"
attempt.save()
assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user=self.user, window=window))
@ddt.ddt
class VerificationCheckpointTest(ModuleStoreTestCase):
"""Tests for the VerificationCheckpoint model. """
MIDTERM = "midterm"
FINAL = "final"
def setUp(self):
super(VerificationCheckpointTest, self).setUp()
self.user = UserFactory.create()
self.course = CourseFactory.create()
@ddt.data(MIDTERM, FINAL)
def test_get_verification_checkpoint(self, check_point):
"""testing class method of VerificationCheckpoint. create the object and then uses the class method to get the
verification check point.
"""
# create the VerificationCheckpoint checkpoint
ver_check_point = VerificationCheckpoint.objects.create(course_id=self.course.id, checkpoint_name=check_point)
self.assertEqual(
VerificationCheckpoint.get_verification_checkpoint(self.course.id, check_point),
ver_check_point
)
def test_get_verification_checkpoint_for_not_existing_values(self):
"""testing class method of VerificationCheckpoint. create the object and then uses the class method to get the
verification check point.
"""
# create the VerificationCheckpoint checkpoint
VerificationCheckpoint.objects.create(course_id=self.course.id, checkpoint_name=self.MIDTERM)
# get verification for not existing checkpoint
self.assertEqual(VerificationCheckpoint.get_verification_checkpoint(self.course.id, 'abc'), None)
def test_unique_together_constraint(self):
"""testing the unique together contraint.
"""
# create the VerificationCheckpoint checkpoint
VerificationCheckpoint.objects.create(course_id=self.course.id, checkpoint_name=self.MIDTERM)
# create the VerificationCheckpoint checkpoint with same course id and checkpoint name
with self.assertRaises(IntegrityError):
VerificationCheckpoint.objects.create(course_id=self.course.id, checkpoint_name=self.MIDTERM)
def test_add_verification_attempt_software_secure(self):
"""testing manytomany relationship. adding softwaresecure attempt to the verification checkpoints.
"""
# adding two check points.
check_point1 = VerificationCheckpoint.objects.create(course_id=self.course.id, checkpoint_name=self.MIDTERM)
check_point2 = VerificationCheckpoint.objects.create(course_id=self.course.id, checkpoint_name=self.FINAL)
# Make an attempt and added to the checkpoint1.
check_point1.add_verification_attempt(SoftwareSecurePhotoVerification.objects.create(user=self.user))
self.assertEqual(check_point1.photo_verification.count(), 1)
# Make an other attempt and added to the checkpoint1.
check_point1.add_verification_attempt(SoftwareSecurePhotoVerification.objects.create(user=self.user))
self.assertEqual(check_point1.photo_verification.count(), 2)
# make new attempt and adding to the checkpoint2
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
check_point2.add_verification_attempt(attempt)
self.assertEqual(check_point2.photo_verification.count(), 1)
# remove the attempt from checkpoint2
check_point2.photo_verification.remove(attempt)
self.assertEqual(check_point2.photo_verification.count(), 0)
@ddt.ddt
class VerificationStatusTest(ModuleStoreTestCase):
"""Tests for the VerificationStatus model. """
def setUp(self):
super(VerificationStatusTest, self).setUp()
self.user = UserFactory.create()
self.course = CourseFactory.create()
self.check_point1 = VerificationCheckpoint.objects.create(course_id=self.course.id, checkpoint_name="midterm")
self.check_point2 = VerificationCheckpoint.objects.create(course_id=self.course.id, checkpoint_name="final")
@ddt.data('submitted', "approved", "denied", "error")
def test_add_verification_status(self, status):
"""adding verfication status using the class method."""
# adding verification status
VerificationStatus.add_verification_status(checkpoint=self.check_point1, user=self.user, status=status)
# getting the status from db
result = VerificationStatus.objects.filter(checkpoint=self.check_point1)[0]
self.assertEqual(result.status, status)
self.assertEqual(result.user, self.user)
@ddt.data('submitted', "approved", "denied", "error")
def test_add_status_from_checkpoints(self, status):
"""adding verfication status for checkpoints list."""
# adding verification status with multiple points
VerificationStatus.add_status_from_checkpoints(
checkpoints=[self.check_point1, self.check_point2], user=self.user, status=status
)
# getting the status from db.
result = VerificationStatus.objects.filter(user=self.user)
self.assertEqual(len(result), len([self.check_point1.checkpoint_name, self.check_point2.checkpoint_name]))
self.assertEqual(result[0].checkpoint.checkpoint_name, self.check_point1.checkpoint_name)
self.assertEqual(result[1].checkpoint.checkpoint_name, self.check_point2.checkpoint_name)
......@@ -36,8 +36,14 @@ from course_modes.models import CourseMode
from shoppingcart.models import Order, CertificateItem
from embargo.test_utils import restrict_course
from util.testing import UrlResetMixin
from verify_student.views import render_to_response, PayAndVerifyView
from verify_student.models import SoftwareSecurePhotoVerification
from verify_student.views import (
render_to_response, PayAndVerifyView, EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW,
EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY
)
from verify_student.models import (
SoftwareSecurePhotoVerification, VerificationCheckpoint,
InCourseReverificationConfiguration
)
from reverification.tests.factories import MidcourseReverificationWindowFactory
......@@ -1686,3 +1692,175 @@ class TestReverificationBanner(ModuleStoreTestCase):
self.client.post(reverse('verify_student_toggle_failed_banner_off'))
photo_verification = SoftwareSecurePhotoVerification.objects.get(user=self.user, window=self.window)
self.assertFalse(photo_verification.display)
class TestInCourseReverifyView(ModuleStoreTestCase):
"""
Tests for the incourse reverification views.
"""
IMAGE_DATA = "abcd,1234"
MIDTERM = "midterm"
def setUp(self):
super(TestInCourseReverifyView, self).setUp()
self.user = UserFactory.create(username="rusty", password="test")
self.client.login(username="rusty", password="test")
self.course_key = SlashSeparatedCourseKey("Robot", "999", "Test_Course")
CourseFactory.create(org='Robot', number='999', display_name='Test Course')
# Create the course modes
for mode in ('audit', 'honor', 'verified'):
min_price = 0 if mode in ["honor", "audit"] else 1
CourseModeFactory(mode_slug=mode, course_id=self.course_key, min_price=min_price)
# Enroll the user in the default mode (honor) to emulate
CourseEnrollment.enroll(self.user, self.course_key, mode="verified")
self.config = InCourseReverificationConfiguration(enabled=True)
self.config.save()
# mocking and patching for bi events
analytics_patcher = patch('verify_student.views.analytics')
self.mock_tracker = analytics_patcher.start()
self.addCleanup(analytics_patcher.stop)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_feature_flag_get(self):
self.config.enabled = False
self.config.save()
response = self.client.get(self._get_url(self.course_key, self.MIDTERM))
self.assertEquals(response.status_code, 404)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_invalid_course_get(self):
response = self.client.get(self._get_url("invalid/course/key", self.MIDTERM))
self.assertEquals(response.status_code, 404)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_invalid_checkpoint_get(self):
response = self.client.get(self._get_url(self.course_key, "invalid_checkpoint"))
self.assertEquals(response.status_code, 404)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_initial_redirect_get(self):
self._create_checkpoint()
response = self.client.get(self._get_url(self.course_key, self.MIDTERM))
url = reverse('verify_student_verify_later',
kwargs={"course_id": unicode(self.course_key)})
self.assertRedirects(response, url)
@override_settings(SEGMENT_IO_LMS_KEY="foobar")
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True, 'SEGMENT_IO_LMS': True})
def test_incourse_reverify_get(self):
self._create_checkpoint()
self._create_initial_verification()
response = self.client.get(self._get_url(self.course_key, self.MIDTERM))
self.assertEquals(response.status_code, 200)
#Verify Google Analytics event fired after successfully submiting the picture
self.mock_tracker.track.assert_called_once_with( # pylint: disable=no-member
self.user.id, # pylint: disable=no-member
EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW,
{
'category': "verification",
'label': unicode(self.course_key),
'checkpoint': self.MIDTERM
},
context={
'Google Analytics':
{'clientId': None}
}
)
self.mock_tracker.reset_mock()
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
@patch('verify_student.views.render_to_response', render_mock)
def test_invalid_checkpoint_post(self):
response = self.client.post(self._get_url(self.course_key, self.MIDTERM))
self.assertEquals(response.status_code, 200)
((template, context), _kwargs) = render_mock.call_args # pylint: disable=unpacking-non-sequence
self.assertIn('incourse_reverify', template)
self.assertTrue(context['error'])
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_initial_redirect_post(self):
self._create_checkpoint()
response = self.client.post(self._get_url(self.course_key, self.MIDTERM))
url = reverse('verify_student_verify_later',
kwargs={"course_id": unicode(self.course_key)})
self.assertRedirects(response, url)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_index_error_post(self):
self._create_checkpoint()
self._create_initial_verification()
response = self.client.post(self._get_url(self.course_key, self.MIDTERM), {"face_image": ""})
self.assertEqual(response.status_code, 400)
@override_settings(SEGMENT_IO_LMS_KEY="foobar")
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True, 'SEGMENT_IO_LMS': True})
def test_incourse_reverify_post(self):
self._create_checkpoint()
self._create_initial_verification()
response = self.client.post(self._get_url(self.course_key, self.MIDTERM), {"face_image": self.IMAGE_DATA})
self.assertEqual(response.status_code, 200)
#Verify Google Analytics event fired after successfully submiting the picture
self.mock_tracker.track.assert_called_once_with( # pylint: disable=no-member
self.user.id, # pylint: disable=no-member
EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY,
{
'category': "verification",
'label': unicode(self.course_key),
'checkpoint': self.MIDTERM
},
context={
'Google Analytics':
{'clientId': None}
}
)
self.mock_tracker.reset_mock()
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_feature_flag_post(self):
self.config.enabled = False
self.config.save()
response = self.client.post(self._get_url(self.course_key, self.MIDTERM))
self.assertEquals(response.status_code, 404)
def _create_checkpoint(self):
"""helper method for creating checkpoint"""
checkpoint = VerificationCheckpoint(course_id=self.course_key, checkpoint_name=self.MIDTERM)
checkpoint.save()
def _create_initial_verification(self):
"""helper method for initial verification"""
attempt = SoftwareSecurePhotoVerification(user=self.user)
attempt.mark_ready()
attempt.save()
attempt.submit()
def _get_url(self, course_key, checkpoint):
"""contruct the url.
Arguments:
course_key (unicode): The ID of the course.
checkpoint (str): The verification checkpoint
Returns:
url
"""
return reverse('verify_student_incourse_reverify',
kwargs={"course_id": unicode(course_key), "checkpoint_name": checkpoint})
......@@ -139,4 +139,14 @@ urlpatterns = patterns(
views.submit_photos_for_verification,
name="verify_student_submit_photos"
),
# Endpoint for in-course reverification
# Users are sent to this end-point from within courseware
# to re-verify their identities by re-submitting face photos.
url(
r'^reverify/{course_id}/{checkpoint}/$'.format(
course_id=settings.COURSE_ID_PATTERN, checkpoint=settings.CHECKPOINT_PATTERN
),
views.InCourseReverifyView.as_view(),
name="verify_student_incourse_reverify"
),
)
......@@ -43,7 +43,9 @@ from shoppingcart.processors import (
)
from verify_student.models import (
SoftwareSecurePhotoVerification,
)
VerificationCheckpoint,
VerificationStatus,
InCourseReverificationConfiguration)
from reverification.models import MidcourseReverificationWindow
import ssencrypt
from .exceptions import WindowExpiredException
......@@ -51,6 +53,8 @@ from microsite_configuration import microsite
from embargo import api as embargo_api
from util.json_request import JsonResponse
from util.date_utils import get_default_time_display
from eventtracking import tracker
import analytics
log = logging.getLogger(__name__)
......@@ -59,6 +63,9 @@ EVENT_NAME_USER_ENTERED_MIDCOURSE_REVERIFY_VIEW = 'edx.course.enrollment.reverif
EVENT_NAME_USER_SUBMITTED_MIDCOURSE_REVERIFY = 'edx.course.enrollment.reverify.submitted'
EVENT_NAME_USER_REVERIFICATION_REVIEWED_BY_SOFTWARESECURE = 'edx.course.enrollment.reverify.reviewed'
EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW = 'edx.bi.reverify.started'
EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY = 'edx.bi.reverify.submitted'
class PayAndVerifyView(View):
"""View for the "verify and pay" flow.
......@@ -845,15 +852,20 @@ def results_callback(request):
log.error("Software Secure posted back for receipt_id {}, but not found".format(receipt_id))
return HttpResponseBadRequest("edX ID {} not found".format(receipt_id))
checkpoints = VerificationCheckpoint.objects.filter(photo_verification=attempt).all()
if result == "PASS":
log.debug("Approving verification for {}".format(receipt_id))
attempt.approve()
status = "approved"
elif result == "FAIL":
log.debug("Denying verification for {}".format(receipt_id))
attempt.deny(json.dumps(reason), error_code=error_code)
status = "denied"
elif result == "SYSTEM FAIL":
log.debug("System failure for {} -- resetting to must_retry".format(receipt_id))
attempt.system_error(json.dumps(reason), error_code=error_code)
status = "error"
log.error("Software Secure callback attempt for %s failed: %s", receipt_id, reason)
else:
log.error("Software Secure returned unknown result {}".format(result))
......@@ -866,7 +878,7 @@ def results_callback(request):
course_id = attempt.window.course_id
course_enrollment = CourseEnrollment.get_or_create_enrollment(attempt.user, course_id)
course_enrollment.emit_event(EVENT_NAME_USER_REVERIFICATION_REVIEWED_BY_SOFTWARESECURE)
VerificationStatus.add_status_from_checkpoints(checkpoints=checkpoints, user=attempt.user, status=status)
return HttpResponse("OK!")
......@@ -1064,3 +1076,138 @@ def reverification_window_expired(_request):
"""
# TODO need someone to review the copy for this template
return render_to_response("verify_student/reverification_window_expired.html")
class InCourseReverifyView(View):
"""
The in-course reverification view.
Needs to perform these functions:
- take new face photo
- retrieve the old id photo
- submit these photos to photo verification service
Does not need to worry about pricing
"""
@method_decorator(login_required)
def get(self, request, course_id, checkpoint_name):
""" Display the view for face photo submission"""
# Check the in-course re-verification is enabled or not
incourse_reverify_enabled = InCourseReverificationConfiguration.current().enabled
if not incourse_reverify_enabled:
raise Http404
user = request.user
course_key = CourseKey.from_string(course_id)
course = modulestore().get_course(course_key)
if course is None:
raise Http404
checkpoint = VerificationCheckpoint.get_verification_checkpoint(course_key, checkpoint_name)
if checkpoint is None:
raise Http404
init_verification = SoftwareSecurePhotoVerification.get_initial_verification(user)
if not init_verification:
return redirect(reverse('verify_student_verify_later', kwargs={'course_id': unicode(course_key)}))
# emit the reverification event
self._track_reverification_events(
EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW, user.id, course_id, checkpoint_name
)
context = {
'course_key': unicode(course_key),
'course_name': course.display_name_with_default,
'checkpoint_name': checkpoint_name,
'platform_name': settings.PLATFORM_NAME,
}
return render_to_response("verify_student/incourse_reverify.html", context)
@method_decorator(login_required)
def post(self, request, course_id, checkpoint_name):
"""Submits the re-verification attempt to SoftwareSecure
Args:
request(HttpRequest): HttpRequest object
course_id(str): Course Id
checkpoint_name(str): Checkpoint name
Returns:
HttpResponse with status_code 400 if photo is missing or any error
or redirect to verify_student_verify_later url if initial verification doesn't exist otherwise
HttpsResponse with status code 200
"""
# Check the in-course re-verification is enabled or not
incourse_reverify_enabled = InCourseReverificationConfiguration.current().enabled
if not incourse_reverify_enabled:
raise Http404
user = request.user
course_key = CourseKey.from_string(course_id)
course = modulestore().get_course(course_key)
checkpoint = VerificationCheckpoint.get_verification_checkpoint(course_key, checkpoint_name)
if checkpoint is None:
log.error("Checkpoint is not defined. Could not submit verification attempt for user %s",
request.user.id)
context = {
'course_key': unicode(course_key),
'course_name': course.display_name_with_default,
'checkpoint_name': checkpoint_name,
'error': True,
'errorMsg': _("No checkpoint found"),
'platform_name': settings.PLATFORM_NAME,
}
return render_to_response("verify_student/incourse_reverify.html", context)
init_verification = SoftwareSecurePhotoVerification.get_initial_verification(user)
if not init_verification:
log.error("Could not submit verification attempt for user %s", request.user.id)
return redirect(reverse('verify_student_verify_later', kwargs={'course_id': unicode(course_key)}))
try:
attempt = SoftwareSecurePhotoVerification.submit_faceimage(
request.user, request.POST['face_image'], init_verification.photo_id_key
)
checkpoint.add_verification_attempt(attempt)
VerificationStatus.add_verification_status(checkpoint, user, "submitted")
# emit the reverification event
self._track_reverification_events(
EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY, user.id, course_id, checkpoint_name
)
return HttpResponse()
except IndexError:
log.exception("Invalid image data during photo verification.")
return HttpResponseBadRequest(_("Invalid image data during photo verification."))
except Exception: # pylint: disable=broad-except
log.exception("Could not submit verification attempt for user {}.").format(request.user.id)
msg = _("Could not submit photos")
return HttpResponseBadRequest(msg)
def _track_reverification_events(self, event_name, user_id, course_id, checkpoint): # pylint: disable=invalid-name
"""Track re-verification events for user against course checkpoints
Arguments:
user_id (str): The ID of the user generting the certificate.
course_id (unicode): id associated with the course
checkpoint (str): checkpoint name
Returns:
None
"""
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
tracking_context = tracker.get_tracker().resolve_context()
analytics.track(
user_id,
event_name,
{
'category': "verification",
'label': unicode(course_id),
'checkpoint': checkpoint
},
context={
'Google Analytics': {
'clientId': tracking_context.get('client_id')
}
}
)
......@@ -1180,6 +1180,15 @@ verify_student_js = [
'js/verify_student/pay_and_verify.js',
]
reverify_js = [
'js/verify_student/views/error_view.js',
'js/verify_student/views/image_input_view.js',
'js/verify_student/views/webcam_photo_view.js',
'js/verify_student/models/reverification_model.js',
'js/verify_student/views/incourse_reverify_view.js',
'js/verify_student/incourse_reverify.js',
]
PIPELINE_CSS = {
'style-vendor': {
'source_filenames': [
......@@ -1369,6 +1378,10 @@ PIPELINE_JS = {
'verify_student': {
'source_filenames': verify_student_js,
'output_filename': 'js/verify_student.js'
},
'reverify': {
'source_filenames': reverify_js,
'output_filename': 'js/reverify.js'
}
}
......@@ -2193,3 +2206,6 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
ECOMMERCE_API_URL = None
ECOMMERCE_API_SIGNING_KEY = None
ECOMMERCE_API_TIMEOUT = 5
# Reverification checkpoint name pattern
CHECKPOINT_PATTERN = r'(?P<checkpoint_name>\w+)'
/**
* Set up the in-course reverification page.
*
* This loads data from the DOM's "data-*" attributes
* and uses these to initialize the top-level views
* on the page.
*/
var edx = edx || {};
(function( $, _ ) {
'use strict';
var errorView,
el = $('#incourse-reverify-container');
edx.verify_student = edx.verify_student || {};
errorView = new edx.verify_student.ErrorView({
el: $('#error-container')
});
return new edx.verify_student.InCourseReverifyView({
courseKey: el.data('course-key'),
checkpointName: el.data('checkpoint-name'),
platformName: el.data('platform-name'),
errorModel: errorView.model
}).render();
})( jQuery, _ );
/**
* Model for a reverification attempt.
*
* The re-verification model is responsible for
* storing face photo image data and submitting
* it back to the server.
*/
var edx = edx || {};
(function( $, _, Backbone ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.ReverificationModel = Backbone.Model.extend({
defaults: {
courseKey: '',
checkpointName: '',
faceImage: '',
},
sync: function( method ) {
var model = this;
var headers = { 'X-CSRFToken': $.cookie( 'csrftoken' ) },
data = {
face_image: model.get( 'faceImage' ),
},
url = _.str.sprintf(
'/verify_student/reverify/%(courseKey)s/%(checkpointName)s/', {
courseKey: model.get('courseKey'),
checkpointName: model.get('checkpointName')
}
);
$.ajax({
url: url,
type: 'POST',
data: data,
headers: headers,
success: function() {
model.trigger( 'sync' );
},
error: function( error ) {
model.trigger( 'error', error );
}
});
}
});
})( jQuery, _, Backbone );
/**
* View for in-course reverification.
*
* This view is responsible for rendering the page
* template, including any subviews (for photo capture).
*/
var edx = edx || {};
(function( $, _, _s, Backbone, gettext ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.InCourseReverifyView = Backbone.View.extend({
el: '#incourse-reverify-container',
templateId: '#incourse_reverify-tpl',
submitButtonId: '#submit',
events: {
'click #submit': 'submitPhoto'
},
initialize: function( obj ) {
_.mixin( _s.exports() );
this.errorModel = obj.errorModel || null;
this.courseKey = obj.courseKey || null;
this.checkpointName = obj.checkpointName || null;
this.platformName = obj.platformName || null;
this.model = new edx.verify_student.ReverificationModel({
courseKey: this.courseKey,
checkpointName: this.checkpointName
});
this.listenTo( this.model, 'sync', _.bind( this.handleSubmitPhotoSuccess, this ));
this.listenTo( this.model, 'error', _.bind( this.handleSubmissionError, this ));
this.render();
},
render: function() {
var renderedTemplate = _.template(
$( this.templateId ).html(),
{
courseKey: this.courseKey,
checkpointName: this.checkpointName,
platformName: this.platformName
}
);
$( this.el ).html( renderedTemplate );
// Render the webcam view *after* the parent view
// so that the container div for the webcam
// exists in the DOM.
this.renderWebcam();
return this;
},
renderWebcam: function() {
edx.verify_student.getSupportedWebcamView({
el: $( '#webcam' ),
model: this.model,
modelAttribute: 'faceImage',
submitButton: this.submitButtonId,
errorModel: this.errorModel
}).render();
},
submitPhoto: function() {
// disable the submit button to prevent multiple submissions.
this.setSubmitButtonEnabled(false)
this.model.save();
},
handleSubmitPhotoSuccess: function() {
// Eventually this will be a redirect back into the courseware,
// but for now we can return to the student dashboard.
window.location.href = '/dashboard';
},
handleSubmissionError: function(xhr) {
var errorMsg = gettext( 'An error has occurred. Please try again later.' );
// Re-enable the submit button to allow the user to retry
this.setSubmitButtonEnabled( true );
if ( xhr.status === 400 ) {
errorMsg = xhr.responseText;
}
this.errorModel.set({
errorTitle: gettext( 'Could not submit photos' ),
errorMsg: errorMsg,
shown: true
});
},
setSubmitButtonEnabled: function( isEnabled ) {
$(this.submitButtonId)
.toggleClass( 'is-disabled', !isEnabled )
.prop( 'disabled', !isEnabled )
.attr('aria-disabled', !isEnabled);
}
});
})(jQuery, _, _.str, Backbone, gettext);
// Updates for decoupled verification A/B test
.verification-process {
.pay-and-verify {
.pay-and-verify, .incourse-reverify {
.review {
.title.center-col {
padding: 0 calc( ( 100% - 750px ) / 2 ) 10px;
......
<%!
import json
from django.utils.translation import ugettext as _
from verify_student.views import PayAndVerifyView
%>
<%namespace name='static' file='../static_content.html'/>
<%inherit file="../main.html" />
<%block name="bodyclass">register verification-process step-requirements</%block>
<%block name="pagetitle">
${_("Verifying for \"{checkpoint_name}\" in course \"{course_name}\"").format(course_name=course_name,
checkpoint_name=checkpoint_name)}
</%block>
<%block name="header_extras">
% for template_name in ["incourse_reverify", "webcam_photo", "image_input", "error"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="verify_student/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="js_extra">
<%static:js group='rwd_header_footer'/>
<script src="${static.url('js/vendor/underscore-min.js')}"></script>
<script src="${static.url('js/vendor/underscore.string.min.js')}"></script>
<script src="${static.url('js/vendor/backbone-min.js')}"></script>
<script src="${static.url('js/src/tooltip_manager.js')}"></script>
<%static:js group='reverify'/>
</%block>
<%block name="content">
## Top-level wrapper for errors
## JavaScript views may append to this wrapper
<div id="error-container" style="display: none;"></div>
<div class="container">
<section class="wrapper carousel">
## Container for the reverification view.
## The Backbone view renders itself into this <div>.
## The server can pass information to the Backbone view
## by including the information as "data-*" attributes
## of this </div>.
<div id="incourse-reverify-container"
class="incourse-reverify"
data-course-key='${course_key}'
data-checkpoint-name='${checkpoint_name}'
data-platform-name='${platform_name}'
></div>
</section>
</div>
</%block>
<div id="wrapper-facephoto" class="wrapper-view block-photo face-photo-step">
<div class="facephoto view">
<h3 class="title"><%- gettext( "Take Your Photo" ) %></h3>
<p><%= gettext("Course key: ") + courseKey %></p>
<p><%= gettext("Checkpoint name: ") + checkpointName %></p>
<div class="instruction">
<p><%- gettext( "Use your webcam to take a photo of your face. We will match this photo with the photo on your ID." ) %></p>
</div>
<div class="wrapper-task">
<div id="webcam" class="task cam"></div>
<div class="wrapper-help">
<div class="help help-task photo-tips facetips">
<h4 class="title"><%- gettext( "Tips on taking a successful photo" ) %></h4>
<div class="copy">
<ul class="list-help">
<li class="help-item"><%- gettext( "Make sure your face is well-lit" ) %></li>
<li class="help-item"><%- gettext( "Be sure your entire face is inside the frame" ) %></li>
<li class="help-item">
<%= _.sprintf( gettext( "Once in position, use the camera button %(icon)s to capture your photo" ), { icon: '<span class="example">(<i class="icon fa fa-camera" aria-hidden="true"></i>)</span>' } ) %>
</li>
<li class="help-item"><%- gettext( "Can we match the photo you took with the one on your ID?" ) %></li>
<li class="help-item"><%- gettext( "Use the retake photo button if you are not pleased with your photo" ) %></li>
</ul>
</div>
</div>
<div class="help help-faq facefaq">
<h4 class="sr title"><%- gettext( "Frequently Asked Questions" ) %></h4>
<div class="copy">
<dl class="list-faq">
<dt class="faq-question">
<%- _.sprintf( gettext( "Why does %(platformName)s need my photo?" ), { platformName: platformName } ) %>
</dt>
<dd class="faq-answer"><%- gettext( "As part of the verification process, you take a photo of both your face and a government-issued photo ID. Our authorization service confirms your identity by comparing the photo you take with the photo on your ID." ) %></dd>
<dt class="faq-question">
<%- _.sprintf( gettext( "What does %(platformName)s do with this photo?" ), { platformName: platformName } ) %>
</dt>
<dd class="faq-answer"><%- _.sprintf( gettext( "We use the highest levels of security available to encrypt your photo and send it to our authorization service for review. Your photo and information are not saved or visible anywhere on %(platformName)s after the verification process is complete." ), { platformName: platformName } ) %></dd>
</dl>
</div>
</div>
</div>
</div>
<div>
<button class="action action-primary" id="submit"><%= gettext("Submit") %></button>
</div>
</div>
</div>
Markdown is supported
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