From 1d8b0e3e897d9e3dd5dbbaa5dfeda43b6846ffa1 Mon Sep 17 00:00:00 2001 From: Awais Date: Wed, 25 Mar 2015 21:23:55 +0500 Subject: [PATCH] ECOM-1205-adding models and tests --- lms/djangoapps/verify_student/admin.py | 3 +- ...t__add_unique_verificationcheckpoint_co.py | 142 ++++++++++++++ ...add_incoursereverificationconfiguration.py | 118 ++++++++++++ lms/djangoapps/verify_student/models.py | 142 ++++++++++++++ .../verify_student/tests/test_models.py | 112 ++++++++++- .../verify_student/tests/test_views.py | 182 +++++++++++++++++- lms/djangoapps/verify_student/urls.py | 10 + lms/djangoapps/verify_student/views.py | 151 ++++++++++++++- lms/envs/common.py | 16 ++ .../js/verify_student/incourse_reverify.js | 28 +++ .../models/reverification_model.js | 51 +++++ .../views/incourse_reverify_view.js | 107 ++++++++++ .../sass/views/_decoupled-verification.scss | 2 +- .../verify_student/incourse_reverify.html | 52 +++++ .../incourse_reverify.underscore | 55 ++++++ 15 files changed, 1164 insertions(+), 7 deletions(-) create mode 100644 lms/djangoapps/verify_student/migrations/0004_auto__add_verificationcheckpoint__add_unique_verificationcheckpoint_co.py create mode 100644 lms/djangoapps/verify_student/migrations/0005_auto__add_incoursereverificationconfiguration.py create mode 100644 lms/static/js/verify_student/incourse_reverify.js create mode 100644 lms/static/js/verify_student/models/reverification_model.js create mode 100644 lms/static/js/verify_student/views/incourse_reverify_view.js create mode 100644 lms/templates/verify_student/incourse_reverify.html create mode 100644 lms/templates/verify_student/incourse_reverify.underscore diff --git a/lms/djangoapps/verify_student/admin.py b/lms/djangoapps/verify_student/admin.py index 9f96ca920b..b13b775ad4 100644 --- a/lms/djangoapps/verify_student/admin.py +++ b/lms/djangoapps/verify_student/admin.py @@ -1,4 +1,5 @@ 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) diff --git a/lms/djangoapps/verify_student/migrations/0004_auto__add_verificationcheckpoint__add_unique_verificationcheckpoint_co.py b/lms/djangoapps/verify_student/migrations/0004_auto__add_verificationcheckpoint__add_unique_verificationcheckpoint_co.py new file mode 100644 index 0000000000..41a8ed7403 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0004_auto__add_verificationcheckpoint__add_unique_verificationcheckpoint_co.py @@ -0,0 +1,142 @@ +# -*- 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 'VerificationCheckpoint' + db.create_table('verify_student_verificationcheckpoint', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + ('checkpoint_name', self.gf('django.db.models.fields.CharField')(max_length=32)), + )) + db.send_create_signal('verify_student', ['VerificationCheckpoint']) + + # Adding M2M table for field photo_verification on 'VerificationCheckpoint' + m2m_table_name = db.shorten_name('verify_student_verificationcheckpoint_photo_verification') + db.create_table(m2m_table_name, ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('verificationcheckpoint', models.ForeignKey(orm['verify_student.verificationcheckpoint'], null=False)), + ('softwaresecurephotoverification', models.ForeignKey(orm['verify_student.softwaresecurephotoverification'], null=False)) + )) + db.create_unique(m2m_table_name, ['verificationcheckpoint_id', 'softwaresecurephotoverification_id']) + + # Adding unique constraint on 'VerificationCheckpoint', fields ['course_id', 'checkpoint_name'] + db.create_unique('verify_student_verificationcheckpoint', ['course_id', 'checkpoint_name']) + + # Adding model 'VerificationStatus' + db.create_table('verify_student_verificationstatus', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('checkpoint', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['verify_student.VerificationCheckpoint'])), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('status', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('timestamp', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('response', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('error', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + )) + db.send_create_signal('verify_student', ['VerificationStatus']) + + + def backwards(self, orm): + # Removing unique constraint on 'VerificationCheckpoint', fields ['course_id', 'checkpoint_name'] + db.delete_unique('verify_student_verificationcheckpoint', ['course_id', 'checkpoint_name']) + + # Deleting model 'VerificationCheckpoint' + db.delete_table('verify_student_verificationcheckpoint') + + # Removing M2M table for field photo_verification on 'VerificationCheckpoint' + db.delete_table(db.shorten_name('verify_student_verificationcheckpoint_photo_verification')) + + # Deleting model 'VerificationStatus' + db.delete_table('verify_student_verificationstatus') + + + 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.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': "'4f091843-1377-4d3b-af5d-3a4ae3d17943'", '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', [], {'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'] diff --git a/lms/djangoapps/verify_student/migrations/0005_auto__add_incoursereverificationconfiguration.py b/lms/djangoapps/verify_student/migrations/0005_auto__add_incoursereverificationconfiguration.py new file mode 100644 index 0000000000..e0c3a3cb75 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0005_auto__add_incoursereverificationconfiguration.py @@ -0,0 +1,118 @@ +# -*- 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'] + diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 80f872c012..561c59d585 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -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 diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index fe5fddcebb..4348f367fd 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -1,11 +1,13 @@ # -*- 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) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 1e6965e336..1a381e3b9a 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -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}) diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 2547b66d4c..03fcb4959c 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -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" + ), ) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 27b0cd66e6..39dca7c193 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -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') + } + } + ) diff --git a/lms/envs/common.py b/lms/envs/common.py index c36731e15c..abdfdbd01a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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\w+)' diff --git a/lms/static/js/verify_student/incourse_reverify.js b/lms/static/js/verify_student/incourse_reverify.js new file mode 100644 index 0000000000..d6a65bf384 --- /dev/null +++ b/lms/static/js/verify_student/incourse_reverify.js @@ -0,0 +1,28 @@ +/** + * 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, _ ); diff --git a/lms/static/js/verify_student/models/reverification_model.js b/lms/static/js/verify_student/models/reverification_model.js new file mode 100644 index 0000000000..e9152d8e7a --- /dev/null +++ b/lms/static/js/verify_student/models/reverification_model.js @@ -0,0 +1,51 @@ +/** + * 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 ); diff --git a/lms/static/js/verify_student/views/incourse_reverify_view.js b/lms/static/js/verify_student/views/incourse_reverify_view.js new file mode 100644 index 0000000000..0d9883bb4c --- /dev/null +++ b/lms/static/js/verify_student/views/incourse_reverify_view.js @@ -0,0 +1,107 @@ +/** + * 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); diff --git a/lms/static/sass/views/_decoupled-verification.scss b/lms/static/sass/views/_decoupled-verification.scss index 7cdef0bf63..d77f921068 100644 --- a/lms/static/sass/views/_decoupled-verification.scss +++ b/lms/static/sass/views/_decoupled-verification.scss @@ -1,6 +1,6 @@ // 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; diff --git a/lms/templates/verify_student/incourse_reverify.html b/lms/templates/verify_student/incourse_reverify.html new file mode 100644 index 0000000000..fae9dcaddf --- /dev/null +++ b/lms/templates/verify_student/incourse_reverify.html @@ -0,0 +1,52 @@ +<%! +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 name="pagetitle"> + ${_("Verifying for \"{checkpoint_name}\" in course \"{course_name}\"").format(course_name=course_name, +checkpoint_name=checkpoint_name)} + + +<%block name="header_extras"> + % for template_name in ["incourse_reverify", "webcam_photo", "image_input", "error"]: + + % endfor + +<%block name="js_extra"> + <%static:js group='rwd_header_footer'/> + + + + + <%static:js group='reverify'/> + + +<%block name="content"> +## Top-level wrapper for errors +## JavaScript views may append to this wrapper + + +
+ +
+ diff --git a/lms/templates/verify_student/incourse_reverify.underscore b/lms/templates/verify_student/incourse_reverify.underscore new file mode 100644 index 0000000000..381f77bd09 --- /dev/null +++ b/lms/templates/verify_student/incourse_reverify.underscore @@ -0,0 +1,55 @@ +
+
+

<%- gettext( "Take Your Photo" ) %>

+

<%= gettext("Course key: ") + courseKey %>

+

<%= gettext("Checkpoint name: ") + checkpointName %>

+
+

<%- gettext( "Use your webcam to take a photo of your face. We will match this photo with the photo on your ID." ) %>

+
+ +
+
+ +
+
+

<%- gettext( "Tips on taking a successful photo" ) %>

+ +
+
    +
  • <%- gettext( "Make sure your face is well-lit" ) %>
  • +
  • <%- gettext( "Be sure your entire face is inside the frame" ) %>
  • +
  • + <%= _.sprintf( gettext( "Once in position, use the camera button %(icon)s to capture your photo" ), { icon: '()' } ) %> +
  • +
  • <%- gettext( "Can we match the photo you took with the one on your ID?" ) %>
  • +
  • <%- gettext( "Use the retake photo button if you are not pleased with your photo" ) %>
  • +
+
+
+ +
+

<%- gettext( "Frequently Asked Questions" ) %>

+
+
+
+ <%- _.sprintf( gettext( "Why does %(platformName)s need my photo?" ), { platformName: platformName } ) %> +
+
<%- 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." ) %>
+
+ <%- _.sprintf( gettext( "What does %(platformName)s do with this photo?" ), { platformName: platformName } ) %> +
+
<%- _.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 } ) %>
+
+
+
+
+
+ + +
+ +
+ + +
+