From 85030467ed0f24797599212d9bc5b84a4c1bedae Mon Sep 17 00:00:00 2001 From: Julia Hansbrough Date: Mon, 13 Jan 2014 20:10:32 +0000 Subject: [PATCH 01/14] Skeleton for follow-on verification behavior Quick-and-dirty end-to-end flow, functional for at least the case of one course in need of re-verification. Ready for design to start taking a look; still needs tests and code cleanup --- common/djangoapps/student/views.py | 26 +- lms/djangoapps/certificates/queue.py | 13 +- lms/djangoapps/verify_student/admin.py | 2 + ...auto__add_midcoursereverificationwindow.py | 91 +++++ ...03_auto__add_sspmidcoursereverification.py | 94 +++++ lms/djangoapps/verify_student/models.py | 273 ++++++++++++++- lms/djangoapps/verify_student/urls.py | 18 + lms/djangoapps/verify_student/views.py | 91 ++++- .../js/verify_student/photocapturebasic2.js | 322 ++++++++++++++++++ lms/templates/dashboard.html | 7 + .../_dashboard_prompt_midcourse_reverify.html | 13 + .../midcourse_photo_reverification.html | 201 +++++++++++ ...midcourse_reverification_confirmation.html | 45 +++ .../midcourse_reverify_dash.html | 28 ++ .../prompt_midcourse_reverify.html | 6 + 15 files changed, 1219 insertions(+), 11 deletions(-) create mode 100644 lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow.py create mode 100644 lms/djangoapps/verify_student/migrations/0003_auto__add_sspmidcoursereverification.py create mode 100644 lms/static/js/verify_student/photocapturebasic2.js create mode 100644 lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html create mode 100644 lms/templates/verify_student/midcourse_photo_reverification.html create mode 100644 lms/templates/verify_student/midcourse_reverification_confirmation.html create mode 100644 lms/templates/verify_student/midcourse_reverify_dash.html create mode 100644 lms/templates/verify_student/prompt_midcourse_reverify.html diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 5ab43a161d..ea0c2a5114 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -10,6 +10,8 @@ import string # pylint: disable=W0402 import urllib import uuid import time +import datetime +from pytz import UTC from django.conf import settings from django.contrib.auth import logout, authenticate, login @@ -45,7 +47,7 @@ from student.models import ( ) from student.forms import PasswordResetFormNoActive -from verify_student.models import SoftwareSecurePhotoVerification +from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow, SSPMidcourseReverification from certificates.models import CertificateStatuses, certificate_status_for_student from xmodule.course_module import CourseDescriptor @@ -205,6 +207,7 @@ def _cert_info(user, course, cert_status): CertificateStatuses.restricted: 'restricted', } + # TODO: We need the thing on the sidebar to mention if reverification, as per UI flows. status = template_state.get(cert_status['status'], default_status) d = {'status': status, @@ -386,8 +389,27 @@ def dashboard(request): ) # Verification Attempts + # Used to generate the "you must reverify for course x" banner + # TODO: make this banner appear at the top of courseware as well verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) + # TODO: Factor this out into a function; I'm pretty sure there's code duplication floating around... + prompt_midcourse_reverify = False + reverify_course_data = [] + for (course, enrollment) in course_enrollment_pairs: + if MidcourseReverificationWindow.window_open_for_course(course.id) and not SSPMidcourseReverification.user_has_valid_or_pending(user, course.id): + window = MidcourseReverificationWindow.get_window(course.id, datetime.datetime.now(UTC)) + status_for_window = SSPMidcourseReverification.get_status_for_window(user, window) + reverify_course_data.append( + ( + course.id, + course.display_name, + window.end_date, + "must_reverify" # TODO: reflect more states than just "must_reverify" has_valid_or_pending (must show failure) + ) + ) + prompt_midcourse_reverify = True + show_refund_option_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs if _enrollment.refundable()) @@ -408,6 +430,8 @@ def dashboard(request): 'all_course_modes': course_modes, 'cert_statuses': cert_statuses, 'show_email_settings_for': show_email_settings_for, + 'prompt_midcourse_reverify': prompt_midcourse_reverify, + 'reverify_course_data': reverify_course_data, 'verification_status': verification_status, 'verification_msg': verification_msg, 'show_refund_option_for': show_refund_option_for, diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 2f9e70517a..0cf92ba428 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -179,11 +179,18 @@ class XQueueCertInterface(object): org = course_id.split('/')[0] course_num = course_id.split('/')[1] cert_mode = enrollment_mode - if enrollment_mode == GeneratedCertificate.MODES.verified and SoftwareSecurePhotoVerification.user_is_verified(student): + if ( + (enrollment_mode == GeneratedCertificate.MODES.verified) and + SoftwareSecurePhotoVerification.user_is_verified(student) and + SSPMidcourseReverification.user_is_reverified_for_all(course_id, student) + ): template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( org, course_num) - elif (enrollment_mode == GeneratedCertificate.MODES.verified and not - SoftwareSecurePhotoVerification.user_is_verified(student)): + elif ( + (enrollment_mode == GeneratedCertificate.MODES.verified) and not + (SoftwareSecurePhotoVerification.user_is_verified(student)) and not + (SSPMidcourseReverification.user_is_reverified_for_all(course_id, student)) + ): template_pdf = "certificate-template-{0}-{1}.pdf".format( org, course_num) cert_mode = GeneratedCertificate.MODES.honor diff --git a/lms/djangoapps/verify_student/admin.py b/lms/djangoapps/verify_student/admin.py index 9f96ca920b..fe7c2411a7 100644 --- a/lms/djangoapps/verify_student/admin.py +++ b/lms/djangoapps/verify_student/admin.py @@ -1,4 +1,6 @@ from ratelimitbackend import admin from verify_student.models import SoftwareSecurePhotoVerification +from verify_student.models import MidcourseReverificationWindow admin.site.register(SoftwareSecurePhotoVerification) +admin.site.register(MidcourseReverificationWindow) \ No newline at end of file diff --git a/lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow.py b/lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow.py new file mode 100644 index 0000000000..5543247aed --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +import 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 'MidcourseReverificationWindow' + db.create_table('verify_student_midcoursereverificationwindow', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('start_date', self.gf('django.db.models.fields.DateTimeField')(default=None, null=True, blank=True)), + ('end_date', self.gf('django.db.models.fields.DateTimeField')(default=None, null=True, blank=True)), + )) + db.send_create_signal('verify_student', ['MidcourseReverificationWindow']) + + + def backwards(self, orm): + # Deleting model 'MidcourseReverificationWindow' + db.delete_table('verify_student_midcoursereverificationwindow') + + + 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'}) + }, + 'verify_student.midcoursereverificationwindow': { + 'Meta': {'object_name': 'MidcourseReverificationWindow'}, + 'course_id': ('django.db.models.fields.CharField', [], {'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'}), + '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': "''", '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']"}) + } + } + + complete_apps = ['verify_student'] \ No newline at end of file diff --git a/lms/djangoapps/verify_student/migrations/0003_auto__add_sspmidcoursereverification.py b/lms/djangoapps/verify_student/migrations/0003_auto__add_sspmidcoursereverification.py new file mode 100644 index 0000000000..55720b1d04 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0003_auto__add_sspmidcoursereverification.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +import 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 'SSPMidcourseReverification' + db.create_table('verify_student_sspmidcoursereverification', ( + ('softwaresecurephotoverification_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['verify_student.SoftwareSecurePhotoVerification'], unique=True, primary_key=True)), + ('window', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['verify_student.MidcourseReverificationWindow'])), + )) + db.send_create_signal('verify_student', ['SSPMidcourseReverification']) + + + def backwards(self, orm): + # Deleting model 'SSPMidcourseReverification' + db.delete_table('verify_student_sspmidcoursereverification') + + + 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'}) + }, + 'verify_student.midcoursereverificationwindow': { + 'Meta': {'object_name': 'MidcourseReverificationWindow'}, + 'course_id': ('django.db.models.fields.CharField', [], {'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'}), + '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': "''", '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']"}) + }, + 'verify_student.sspmidcoursereverification': { + 'Meta': {'ordering': "['-created_at']", 'object_name': 'SSPMidcourseReverification', '_ormbases': ['verify_student.SoftwareSecurePhotoVerification']}, + 'softwaresecurephotoverification_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['verify_student.SoftwareSecurePhotoVerification']", 'unique': 'True', 'primary_key': 'True'}), + 'window': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['verify_student.MidcourseReverificationWindow']"}) + } + } + + complete_apps = ['verify_student'] \ No newline at end of file diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index dccbdb430a..a93362d9c9 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -23,6 +23,7 @@ import pytz import requests from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist from django.core.urlresolvers import reverse from django.db import models from django.contrib.auth.models import User @@ -37,6 +38,57 @@ from verify_student.ssencrypt import ( log = logging.getLogger(__name__) +# Evidently South migrations complain a lot if you have a default set to uuid.uuid4, so +# I had to add this function to make South happy, see this for more: +# http://stackoverflow.com/questions/15041265/south-migrate-error-name-uuid-is-not-defined +# If anyone knows a happier solution, do let me know; otherwise I'll remove this comment +# after CR +def generateUUID(): + return str(uuid.uuid4) + +class MidcourseReverificationWindow(models.Model): + """ + Defines the start and end times for midcourse reverification for a particular course. + + There can be many MidcourseReverificationWindows per course, but they should not + have overlapping time-ranges (i.e. Window2's start date should not be before Window1's + start date) (TODO: should the non-overlap constraint be explicitly enforced by the model?) + """ + # the course that this window is attached to + # TODO should this be a foreignkey? + course_id = models.CharField(max_length=255, db_index=True) + start_date = models.DateTimeField(default=None, null=True, blank=True) + end_date = models.DateTimeField(default=None, null=True, blank=True) + + @classmethod + def window_open_for_course(cls, course_id): + """ + Returns a boolean, True if the course is currently asking for reverification, else False. + """ + now = datetime.now(pytz.UTC) + + # We are assuming one window per course_id. TODO find out if this assumption is OK + try: + window = cls.objects.get(course_id=course_id) + except(ObjectDoesNotExist): + return False + + if (window.start_date <= now <= window.end_date): + return True + else: + return False + + @classmethod + def get_window(cls, course_id, date): + """ + Returns the window that is open for a particular course for a particular date. + If no such window is open, or if more than one window is open, returns None. + """ + try: + return cls.objects.get(course_id=course_id, start_date__lte=date, end_date__gte=date) + except Exception: + return None + class VerificationException(Exception): pass @@ -135,7 +187,7 @@ class PhotoVerification(StatusModel): # user IDs or something too easily guessable. receipt_id = models.CharField( db_index=True, - default=uuid.uuid4, + default=generateUUID, max_length=255, ) @@ -167,6 +219,8 @@ class PhotoVerification(StatusModel): # capturing it so that we can later query for the common problems. error_code = models.CharField(blank=True, max_length=50) + + class Meta: abstract = True ordering = ['-created_at'] @@ -483,7 +537,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"] aes_key = aes_key_str.decode("hex") - s3_key = self._generate_key("face") + s3_key = self._generate_s3_key("face") s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key)) @status_before_must_be("created") @@ -510,7 +564,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): rsa_encrypted_aes_key = rsa_encrypt(aes_key, rsa_key_str) # Upload this to S3 - s3_key = self._generate_key("photo_id") + s3_key = self._generate_s3_key("photo_id") s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key)) # Update our record fields @@ -580,11 +634,13 @@ class SoftwareSecurePhotoVerification(PhotoVerification): We dynamically generate this, since we want it the expiration clock to start when the message is created, not when the record is created. """ - s3_key = self._generate_key(name) + s3_key = self._generate_s3_key(name) return s3_key.generate_url(self.IMAGE_LINK_DURATION) - def _generate_key(self, prefix): + def _generate_s3_key(self, prefix): """ + Generates a key for an s3 bucket location + Example: face/4dd1add9-6719-42f7-bea0-115c008c4fca """ conn = S3Connection( @@ -689,3 +745,210 @@ class SoftwareSecurePhotoVerification(PhotoVerification): log.debug("Return message:\n\n{}\n\n".format(response.text)) return response + +class SSPMidcourseReverification(SoftwareSecurePhotoVerification): + """ + Model to re-verify identity using a service provided by Software Secure. + + As of now, it's inheriting a great deal of logic from both `PhotoVerification` + and `SoftwareSecurePhotoVerification`, but it might make more sense to just inherit + from `PhotoVerification`, or maybe not at all... a lot of classes had to get stomped/ + rewritten. Will think about this during CR. + + TODO: another important thing to note during CR: right now we're assuming there's one + window per (user, course) combo. This is UNTRUE in general (there can be many windows + per course, user pair), but we only need ONE window per (user, course) to launch. + Note the user_status methods in particular make this assumption. + + Fix this if time permits... + """ + window = models.ForeignKey(MidcourseReverificationWindow, db_index=True) + + @classmethod + def user_is_reverified_for_all(self, course_id, user): + """ + Checks to see if the student has successfully reverified for all of the + mandatory re-verification windows associated with a course. + + This is used primarily by the certificate generation code... if the user is + not re-verified for all windows, then they cannot receive a certificate. + """ + all_windows = MidcourseReverificationWindow.objects.filter(course_id=course_id) + # TODO check on this + # if there are no windows for a course, then return True right off + if (not all_windows): + return True + for window in all_windows: + try: + # There should be one and only one reverification object per (user, window) + # and the status of that object should be approved + if cls.objects.get(window=window, user=user).status != "approved": + return False + except: + return False + return True + + # TODO does this actually get the original_verification? pretty sure I need to search by date + def original_verification(self): + """ + Returns the most current SoftwareSecurePhotoVerification object associated with the user. + """ + return (SoftwareSecurePhotoVerification.objects.get(user=self.user)) + + # TODO could just call original_verification's _generate_s3_key? + def _generate_original_s3_key(self, prefix): + + #Generates a key into the S3 bucket where the original verification is stored + + #Example: face/4dd1add9-6719-42f7-bea0-115c008c4fca + + conn = S3Connection( + settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["AWS_ACCESS_KEY"], + settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["AWS_SECRET_KEY"] + ) + bucket = conn.get_bucket(settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["S3_BUCKET"]) + + key = Key(bucket) + key.key = "{}/{}".format(prefix, self.original_verification().receipt_id) + + return key + + @status_before_must_be("created") + def fetch_photo_id_image(self): + + #Find the user's photo ID image, which was submitted with their original verification. + #The image has already been encrypted and stored in s3, so we just need to find that + #location + + + if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'): + return + + old_s3_key = self._generate_original_s3_key("face") + new_s3_key = self._generate_s3_key("face") + + original_photo_id = old_s3_key.get_contents_as_string() + + # Unlike upload_face_image, we don't need to encrypt and encode with AES, since that + # was already done when we uploaded it for the initial verification + new_s3_key.set_contents_from_string(original_photo_id) + self.photo_id_key = self.original_verification().photo_id_key + self.save() + + # we replace_photo_id_image with fetch_photo_id_image + @status_before_must_be("created") + def upload_photo_id_image(self, img_data): + raise NotImplementedError + + # TODO right now this does nothing but return must_reverify, fix!!! + @classmethod + def get_status_for_window(cls, user, window): + """ + Returns the status of the user based on their past verification attempts + + If no such verification exists, returns 'must_reverify' + If verification has expired, returns 'expired' --> does this exist for windows? + If the verification has been approved, returns 'approved' + If the verification process is still ongoing, returns 'pending' + If the verification has been denied and the user must resubmit photos, returns 'must_reverify' + """ + reverify_attempt = cls.objects.filter(user=user, window=window) + return "must_reverify" + #if not reverify_attempt: + # return "must_reverify" + #else: + #return reverify_attempt.STATUS + + # can't just inherit the old user_status function, because it's insufficiently specific + # reverifications are unique for a particular (user, window) pair, not just on user + # TODO: Note that a lot of the user_status related stuff is having to get overwritten. + # Does it still make sense to inherit from our parent object(s)? + @classmethod + def user_status(cls, user): + raise NotImplementedError + + @classmethod + def user_status(cls, user, course_id): + """ + Returns the status of the user based on their past verification attempts + + If no such verification exists, returns 'none' + If verification has expired, returns 'expired' + If the verification has been approved, returns 'approved' + If the verification process is still ongoing, returns 'pending' + If the verification has been denied and the user must resubmit photos, returns 'must_reverify' + """ + status = 'none' + error_msg = '' + + if cls.user_is_verified(user): + status = 'approved' + elif cls.user_has_valid_or_pending(user): + # user_has_valid_or_pending does include 'approved', but if we are + # here, we know that the attempt is still pending + status = 'pending' + else: + # we need to check the most recent attempt to see if we need to ask them to do + # a retry + try: + attempts = cls.objects.filter(user=user).order_by('-updated_at') + attempt = attempts[0] + # this is the change for SSPMidcoursePhotoVerification objects + # if there is no verification, we look up course_id, via window, and find out if the user has a verified enrollment + # if verified enrolled in course but no verification: must_reverify + # if not verified enrollment: none + except IndexError: + if CourseEnrollment.objects.filter(user=user, course_id=course_id, mode="verified").exists: + return ('must_reverify', error_msg) + else: + return('none', error_msg) + if attempt.created_at < cls._earliest_allowed_date(): + return ('expired', error_msg) + + # right now, this is the only state at which they must reverify. It + # may change later + if attempt.status == 'denied': + status = 'must_reverify' + if attempt.error_msg: + error_msg = attempt.parsed_error_msg() + + return (status, error_msg) + + # can't inherit + @classmethod + def user_is_verified(cls, user): + raise NotImplementedError + + @classmethod + def user_is_verified(cls, user, course_id): + return cls.objects.filter( + user=user, status="approved", window__course_id=course_id + ).exists() + + # can't inherit + @classmethod + def user_has_valid_or_pending(cls, user): + return NotImplementedError + + # changing this method? + @classmethod + def user_has_valid_or_pending(cls, user, course_id): + valid_statuses = ['submitted', 'approved'] + return cls.objects.filter( + user=user, + window__course_id=course_id, + status__in=valid_statuses, + ).exists() + + # can't inherit + @classmethod + def active_for_user(cls, user): + return NotImplementedError + + @classmethod + def active_for_user(cls, user, course_id): + active_attempts = cls.objects.filter(user=user, status='ready', window__course_id=course_id) + if active_attempts: + return active_attempts[0] + else: + return None diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 843ebf9602..40b434ecea 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -41,9 +41,27 @@ urlpatterns = patterns( name="verify_student_reverify" ), + url( + r'^midcourse_reverify/(?P[^/]+/[^/]+/[^/]+)$', + views.MidCourseReverifyView.as_view(), + name="verify_student_midcourse_reverify" + ), + url( r'^reverification_confirmation$', views.reverification_submission_confirmation, name="verify_student_reverification_confirmation" ), + + url( + r'^midcourse_reverification_confirmation$', + views.midcourse_reverification_confirmation, + name="verify_student_midcourse_reverification_confirmation" + ), + + url( + r'^midcourse_reverify_dash$', + views.midcourse_reverify_dash, + name="verify_student_midcourse_reverify_dash" + ), ) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 2ac798bbf2..d972441d4d 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -5,6 +5,8 @@ Views for the verification flow import json import logging import decimal +import datetime +from pytz import UTC from edxmako.shortcuts import render_to_response @@ -27,7 +29,9 @@ from shoppingcart.models import Order, CertificateItem from shoppingcart.processors.CyberSource import ( get_signed_purchase_params, get_purchase_endpoint ) -from verify_student.models import SoftwareSecurePhotoVerification +from verify_student.models import ( + SoftwareSecurePhotoVerification, MidcourseReverificationWindow, SSPMidcourseReverification +) import ssencrypt log = logging.getLogger(__name__) @@ -322,11 +326,94 @@ class ReverifyView(View): } return render_to_response("verify_student/photo_reverification.html", context) +class MidCourseReverifyView(View): + """ + The mid-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): + """ + display this view + """ + context = { + "user_full_name": request.user.profile.name, + "error": False, + "course_id": course_id, + } + return render_to_response("verify_student/midcourse_photo_reverification.html", context) + + @method_decorator(login_required) + def post(self, request, course_id): + """ + submits the reverification to SoftwareSecure + """ + try: + # TODO look at this more carefully! #1 testing candidate + now = datetime.datetime.now(UTC) + attempt = SSPMidcourseReverification(user=request.user, window=MidcourseReverificationWindow.get_window(course_id, now)) + b64_face_image = request.POST['face_image'].split(",")[1] + + attempt.upload_face_image(b64_face_image.decode('base64')) + attempt.fetch_photo_id_image() + attempt.mark_ready() + + attempt.save() + attempt.submit() + return HttpResponseRedirect(reverse('verify_student_midcourse_reverification_confirmation')) + except Exception: + log.exception( + "Could not submit verification attempt for user {}".format(request.user.id) + ) + context = { + "user_full_name": request.user.profile.name, + "error": True, + } + return render_to_response("verify_student/midcourse_photo_reverification.html", context) + +def midcourse_reverify_dash(_request): + # TODO same comment as in student/views.py: need to factor out this functionality + user = _request.user + course_enrollment_pairs = [] + for enrollment in CourseEnrollment.enrollments_for_user(user): + try: + course_enrollment_pairs.append((course_from_id(enrollment.course_id), enrollment)) + except ItemNotFoundError: + log.error("User {0} enrolled in non-existent course {1}" + .format(user.username, enrollment.course_id)) + reverify_course_data = [] + for (course, enrollment) in course_enrollment_pairs: + if MidcourseReverificationWindow.window_open_for_course(course.id): + reverify_course_data.append( + ( + course.id, + course.display_name, + MidcourseReverificationWindow.get_window(course.id, datetime.datetime.now(UTC)).end_date, + "must_reverify" + ) + ) + prompt_midcourse_reverify = True + context = { + "user_full_name": _request.user.profile.name, + "reverify_course_data": reverify_course_data, + } + return render_to_response("verify_student/midcourse_reverify_dash.html", context) @login_required def reverification_submission_confirmation(_request): """ Shows the user a confirmation page if the submission to SoftwareSecure was successful """ - return render_to_response("verify_student/reverification_confirmation.html") + +@login_required +def midcourse_reverification_confirmation(_request): + """ + Shows the user a confirmation page if the submission to SoftwareSecure was successful + """ + return render_to_response("verify_student/midcourse_reverification_confirmation.html") diff --git a/lms/static/js/verify_student/photocapturebasic2.js b/lms/static/js/verify_student/photocapturebasic2.js new file mode 100644 index 0000000000..0ca03aca4d --- /dev/null +++ b/lms/static/js/verify_student/photocapturebasic2.js @@ -0,0 +1,322 @@ +// TODO diff this against photocapture.js, see if I actually needed a whole honking new file +var onVideoFail = function(e) { + if(e == 'NO_DEVICES_FOUND') { + $('#no-webcam').show(); + $('#face_capture_button').hide(); + } + else { + console.log('Failed to get camera access!', e); + } +}; + +// Returns true if we are capable of video capture (regardless of whether the +// user has given permission). +function initVideoCapture() { + window.URL = window.URL || window.webkitURL; + navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || + navigator.mozGetUserMedia || navigator.msGetUserMedia; + return !(navigator.getUserMedia == undefined); +} + +var submitReverificationPhotos = function() { + // add photos to the form + $('').attr({ + type: 'hidden', + name: 'face_image', + value: $("#face_image")[0].src, + }).appendTo("#reverify_form"); + + $("#reverify_form").submit(); + +} + +var submitToPaymentProcessing = function() { + var contribution_input = $("input[name='contribution']:checked") + var contribution = 0; + if(contribution_input.attr('id') == 'contribution-other') + { + contribution = $("input[name='contribution-other-amt']").val(); + } + else + { + contribution = contribution_input.val(); + } + var course_id = $("input[name='course_id']").val(); + var xhr = $.post( + "/verify_student/create_order", + { + "course_id" : course_id, + "contribution": contribution, + "face_image" : $("#face_image")[0].src, + }, + function(data) { + for (prop in data) { + $('').attr({ + type: 'hidden', + name: prop, + value: data[prop] + }).appendTo('#pay_form'); + } + } + ) + .done(function(data) { + $("#pay_form").submit(); + }) + .fail(function(jqXhr,text_status, error_thrown) { + if(jqXhr.status == 400) { + $('#order-error .copy p').html(jqXhr.responseText); + } + $('#order-error').show(); + $("html, body").animate({ scrollTop: 0 }); + }); +} + +function doResetButton(resetButton, captureButton, approveButton, nextButtonNav, nextLink) { + approveButton.removeClass('approved'); + nextButtonNav.addClass('is-not-ready'); + nextLink.attr('href', "#"); + + captureButton.show(); + resetButton.hide(); + approveButton.hide(); +} + +function doApproveButton(approveButton, nextButtonNav, nextLink) { + nextButtonNav.removeClass('is-not-ready'); + approveButton.addClass('approved'); + nextLink.attr('href', "#next"); +} + +function doSnapshotButton(captureButton, resetButton, approveButton) { + captureButton.hide(); + resetButton.show(); + approveButton.show(); +} + +function submitNameChange(event) { + event.preventDefault(); + $("#lean_overlay").fadeOut(200); + $("#edit-name").css({ 'display' : 'none' }); + var full_name = $('input[name="name"]').val(); + var xhr = $.post( + "/change_name", + { + "new_name" : full_name, + "rationale": "Want to match ID for ID Verified Certificates." + }, + function(data) { + $('#full-name').html(full_name); + } + ) + .fail(function(jqXhr,text_status, error_thrown) { + $('.message-copy').html(jqXhr.responseText); + }); + +} + +function initSnapshotHandler(names, hasHtml5CameraSupport) { + var name = names.pop(); + if (name == undefined) { + return; + } + + var video = $('#' + name + '_video'); + var canvas = $('#' + name + '_canvas'); + var image = $('#' + name + "_image"); + var captureButton = $("#" + name + "_capture_button"); + var resetButton = $("#" + name + "_reset_button"); + var approveButton = $("#" + name + "_approve_button"); + var nextButtonNav = $("#" + name + "_next_button_nav"); + var nextLink = $("#" + name + "_next_link"); + var flashCapture = $("#" + name + "_flash"); + + var ctx = null; + if (hasHtml5CameraSupport) { + ctx = canvas[0].getContext('2d'); + } + + var localMediaStream = null; + + function snapshot(event) { + if (hasHtml5CameraSupport) { + if (localMediaStream) { + ctx.drawImage(video[0], 0, 0); + // TODO put this back eventually + image[0] = image[0]; + image[0].src = image[0].src; + image[0].src = canvas[0].toDataURL('image/png'); + } + else { + return false; + } + video[0].pause(); + } + else { + if (flashCapture[0].cameraAuthorized()) { + image[0].src = flashCapture[0].snap(); + } + else { + return false; + } + } + + doSnapshotButton(captureButton, resetButton, approveButton); + return false; + } + + + function reset() { + image[0].src = ""; + + if (hasHtml5CameraSupport) { + video[0].play(); + } + else { + flashCapture[0].reset(); + } + + doResetButton(resetButton, captureButton, approveButton, nextButtonNav, nextLink); + return false; + } + + function approve() { + doApproveButton(approveButton, nextButtonNav, nextLink) + return false; + } + + // Initialize state for this picture taker + captureButton.show(); + resetButton.hide(); + approveButton.hide(); + nextButtonNav.addClass('is-not-ready'); + nextLink.attr('href', "#"); + + // Connect event handlers... + video.click(snapshot); + captureButton.click(snapshot); + resetButton.click(reset); + approveButton.click(approve); + + // If it's flash-based, we can just immediate initialize the next one. + // If it's HTML5 based, we have to do it in the callback from getUserMedia + // so that Firefox doesn't eat the second request. + // this is the part that's complaining TODO + if (hasHtml5CameraSupport) { + navigator.getUserMedia({video: true}, function(stream) { + video[0].src = window.URL.createObjectURL(stream); + localMediaStream = stream; + + // We do this in a recursive call on success because Firefox seems to + // simply eat the request if you stack up two on top of each other before + // the user has a chance to approve the first one. + initSnapshotHandler(names, hasHtml5CameraSupport); + }, onVideoFail); + } + else { + initSnapshotHandler(names, hasHtml5CameraSupport); + } + +} + +function browserHasFlash() { + var hasFlash = false; + try { + var fo = new ActiveXObject('ShockwaveFlash.ShockwaveFlash'); + if(fo) hasFlash = true; + } catch(e) { + if(navigator.mimeTypes["application/x-shockwave-flash"] != undefined) hasFlash = true; + } + return hasFlash; +} + +function objectTagForFlashCamera(name) { + // detect whether or not flash is available + if(browserHasFlash()) { + // I manually update this to have ?v={2,3,4, etc} to avoid caching of flash + // objects on local dev. + return ''; + } + else { + // display a message informing the user to install flash + $('#no-flash').show(); + } +} + +function linkNewWindow(e) { + window.open($(e.target).attr('href')); + e.preventDefault(); +} + +function waitForFlashLoad(func, flash_object) { + if(!flash_object.hasOwnProperty('percentLoaded') || flash_object.percentLoaded() < 100){ + setTimeout(function() { + waitForFlashLoad(func, flash_object); + }, + 50); + } + else { + func(flash_object); + } +} + +$(document).ready(function() { + $(".carousel-nav").addClass('sr'); + $("#pay_button").click(function(){ + analytics.pageview("Payment Form"); + submitToPaymentProcessing(); + }); + + $("#reverify_button").click(function() { + submitReverificationPhotos(); + }); + + // prevent browsers from keeping this button checked + $("#confirm_pics_good").prop("checked", false) + $("#confirm_pics_good").change(function() { + $("#pay_button").toggleClass('disabled'); + $("#reverify_button").toggleClass('disabled'); + }); + + + // add in handlers to add/remove the correct classes to the body + // when moving between steps + $('#face_next_link').click(function(){ + analytics.pageview("Capture ID Photo"); + $('body').addClass('step-photos-id').removeClass('step-photos-cam') + }) + + $('#photo_id_next_link').click(function(){ + analytics.pageview("Review Photos"); + $('body').addClass('step-review').removeClass('step-photos-id') + }) + + // set up edit information dialog + $('#edit-name div[role="alert"]').hide(); + $('#edit-name .action-save').click(submitNameChange); + + var hasHtml5CameraSupport = initVideoCapture(); + + // If HTML5 WebRTC capture is not supported, we initialize jpegcam + if (!hasHtml5CameraSupport) { + $("#face_capture_div").html(objectTagForFlashCamera("face_flash")); + // wait for the flash object to be loaded and then check for a camera + if(browserHasFlash()) { + waitForFlashLoad(function(flash_object) { + if(!flash_object.hasOwnProperty('hasCamera')){ + onVideoFail('NO_DEVICES_FOUND'); + } + }, $('#face_flash')[0]); + } + } + + analytics.pageview("Capture Face Photo"); + initSnapshotHandler(["face"], hasHtml5CameraSupport); + + $('a[rel="external"]').attr('title', gettext('This link will open in a new browser window/tab')).bind('click', linkNewWindow); + +}); diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 33470d1d0b..397c73fff0 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -154,6 +154,13 @@
+ + %if prompt_midcourse_reverify: +
+ <%include file='dashboard/_dashboard_prompt_midcourse_reverify.html' /> +
+ % endif + %if message:
${message} diff --git a/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html new file mode 100644 index 0000000000..b0c3bb8fa0 --- /dev/null +++ b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html @@ -0,0 +1,13 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + + +% if prompt_midcourse_reverify: +

${_("You need to re-verify to continue")}

+% for course_id, course_name, date, status in reverify_course_data: +

+ ${_('To continue in the verified track in {course_name}, you need to re-verify your identity by {date}.').format(course_name=course_name, date=date)} + Click here to re-verify. +

+% endfor +%endif diff --git a/lms/templates/verify_student/midcourse_photo_reverification.html b/lms/templates/verify_student/midcourse_photo_reverification.html new file mode 100644 index 0000000000..98c464329d --- /dev/null +++ b/lms/templates/verify_student/midcourse_photo_reverification.html @@ -0,0 +1,201 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process is-not-verified step-photos +<%block name="title">${_("Re-Verification")} + +<%block name="js_extra"> + + + + + + +<%block name="content"> + + + + + +%if error: +
+
+ +
+

${_("Error submitting your images")}

+
+

${_("Oops! Something went wrong. Please confirm your details and try again.")}

+
+
+
+
+%endif + +
+
+ +
+
+
+

${_("You are re-verifying your identity")}

+
+ + +
+
+ +
+
+ +
+
+ +
+ + <%include file="_reverification_support.html" /> +
+ + +<%include file="_modal_editname.html" /> + diff --git a/lms/templates/verify_student/midcourse_reverification_confirmation.html b/lms/templates/verify_student/midcourse_reverification_confirmation.html new file mode 100644 index 0000000000..31f6e76538 --- /dev/null +++ b/lms/templates/verify_student/midcourse_reverification_confirmation.html @@ -0,0 +1,45 @@ + +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process is-not-verified step-confirmation +<%block name="title">${_("Re-Verification Submission Confirmation")} + +<%block name="js_extra"> + + + +<%block name="content"> + +
+
+ +
+
+
+
+
+

${_("Your Credentials Have Been Updated")}

+ +
+

${_("We have received your re-verification details and submitted them for review. Your dashboard will show the notification status once the review is complete.")}

+

${_("The professor may ask you to re-verify again at other key points in the course.")}

+
+ +
    + +
+
+
+
+
+
+ + <%include file="_reverification_support.html" /> +
+
+ diff --git a/lms/templates/verify_student/midcourse_reverify_dash.html b/lms/templates/verify_student/midcourse_reverify_dash.html new file mode 100644 index 0000000000..92403c297d --- /dev/null +++ b/lms/templates/verify_student/midcourse_reverify_dash.html @@ -0,0 +1,28 @@ +<%! from django.core.urlresolvers import reverse %> + +

Re-verify

+ +

You currently need to re-verify for the following course:

+ +% for course_id, course_name, date, status in reverify_course_data: +

${course_name}: Re-verify by ${date}. + % if status == "must_reverify": + Re-verify + % elif status == "completed": + Completed + % elif status == "failed": + Failed + % endif +

+% endfor + +

Why do I need to re-verify?

+

At key points in a course, the professor will ask you to re-verify your identity by submitting a new photo of your face. We will send the new photo to be matched up with the photo of the original ID you submitted when you signed up for the course. If you are taking multiple courses, you may need to re-verify multiple times, once for every important point in each course you are taking as a verified student.

+ +

What will I need to re-verify?

+

Because you are just confirming that you are still you, the only thing you will need to do to re-verify is to submit a new photo of your face with your webcam. The process is quick and you will be brought back to where you left off so you can keep on learning.

+ +

If you changed your name during the semester and it no longer matches the original ID you submitted, you will need to re-edit your name to match as well.

+ +

What if I have trouble with my re-verification?

+

Because of the short time that re-verification is open, you will not be able to correct a failed verification. If you think there was an error in the review, please contact us at support@edx.org.

\ No newline at end of file diff --git a/lms/templates/verify_student/prompt_midcourse_reverify.html b/lms/templates/verify_student/prompt_midcourse_reverify.html new file mode 100644 index 0000000000..b160036643 --- /dev/null +++ b/lms/templates/verify_student/prompt_midcourse_reverify.html @@ -0,0 +1,6 @@ +<%! from django.utils.translation import ugettext as _ %> + +

${_("You need to re-verify to continue")}

+

+ ${_("To continue in the verified track in {course}, you need to re-verify your identity by {date}. Go to URL.").format(email)} +

From 6c7d715e0e0a7c5b2fb26d17c252205b45f33772 Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Fri, 17 Jan 2014 16:54:14 -0500 Subject: [PATCH 02/14] re-verification dashboard styles --- common/djangoapps/student/views.py | 29 +- lms/djangoapps/certificates/queue.py | 15 +- lms/djangoapps/verify_student/admin.py | 2 +- ...nwindow__add_field_softwaresecurephoto.py} | 13 +- ...03_auto__add_sspmidcoursereverification.py | 94 ---- lms/djangoapps/verify_student/models.py | 364 +++++---------- .../verify_student/tests/factories.py | 19 + .../verify_student/tests/test_models.py | 172 ++++++- .../verify_student/tests/test_views.py | 62 +++ lms/djangoapps/verify_student/views.py | 20 +- .../js/verify_student/photocapturebasic2.js | 4 +- lms/static/sass/application-extend1.scss.mako | 2 +- lms/static/sass/application-extend2.scss.mako | 3 +- lms/static/sass/application.scss.mako | 2 +- lms/static/sass/base/_mixins.scss | 24 + lms/static/sass/base/_variables.scss | 5 + lms/static/sass/course.scss.mako | 2 +- .../sass/elements/_system-feedback.scss | 74 +++ lms/static/sass/views/_verification.scss | 433 +++++++++++++----- lms/templates/dashboard.html | 15 +- .../_dashboard_prompt_midcourse_reverify.html | 26 +- .../_reverification_support.html | 7 +- .../verify_student/_verification_header.html | 4 + .../midcourse_photo_reverification.html | 44 +- .../midcourse_reverify_dash.html | 93 +++- 25 files changed, 982 insertions(+), 546 deletions(-) rename lms/djangoapps/verify_student/migrations/{0002_auto__add_midcoursereverificationwindow.py => 0002_auto__add_midcoursereverificationwindow__add_field_softwaresecurephoto.py} (90%) delete mode 100644 lms/djangoapps/verify_student/migrations/0003_auto__add_sspmidcoursereverification.py create mode 100644 lms/djangoapps/verify_student/tests/factories.py create mode 100644 lms/static/sass/elements/_system-feedback.scss diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index ea0c2a5114..585da3d713 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -10,7 +10,6 @@ import string # pylint: disable=W0402 import urllib import uuid import time -import datetime from pytz import UTC from django.conf import settings @@ -47,7 +46,7 @@ from student.models import ( ) from student.forms import PasswordResetFormNoActive -from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow, SSPMidcourseReverification +from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow from certificates.models import CertificateStatuses, certificate_status_for_student from xmodule.course_module import CourseDescriptor @@ -394,21 +393,24 @@ def dashboard(request): verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) # TODO: Factor this out into a function; I'm pretty sure there's code duplication floating around... - prompt_midcourse_reverify = False reverify_course_data = [] for (course, enrollment) in course_enrollment_pairs: - if MidcourseReverificationWindow.window_open_for_course(course.id) and not SSPMidcourseReverification.user_has_valid_or_pending(user, course.id): + + # IF the reverification window is open + if (MidcourseReverificationWindow.window_open_for_course(course.id)): + # AND the user is actually verified-enrolled AND they don't have a pending reverification already window = MidcourseReverificationWindow.get_window(course.id, datetime.datetime.now(UTC)) - status_for_window = SSPMidcourseReverification.get_status_for_window(user, window) - reverify_course_data.append( - ( - course.id, - course.display_name, - window.end_date, - "must_reverify" # TODO: reflect more states than just "must_reverify" has_valid_or_pending (must show failure) + if (enrollment.mode == "verified" and not SoftwareSecurePhotoVerification.user_has_valid_or_pending(user, window=window)): + window = MidcourseReverificationWindow.get_window(course.id, datetime.datetime.now(UTC)) + status_for_window = SoftwareSecurePhotoVerification.user_status(user, window=window) + reverify_course_data.append( + ( + course.id, + course.display_name, + window.end_date, + "must_reverify" # TODO: reflect more states than just "must_reverify" has_valid_or_pending (must show failure) + ) ) - ) - prompt_midcourse_reverify = True show_refund_option_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs if _enrollment.refundable()) @@ -430,7 +432,6 @@ def dashboard(request): 'all_course_modes': course_modes, 'cert_statuses': cert_statuses, 'show_email_settings_for': show_email_settings_for, - 'prompt_midcourse_reverify': prompt_midcourse_reverify, 'reverify_course_data': reverify_course_data, 'verification_status': verification_status, 'verification_msg': verification_msg, diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 0cf92ba428..04d1d0ba48 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -176,21 +176,16 @@ class XQueueCertInterface(object): is_whitelisted = self.whitelist.filter( user=student, course_id=course_id, whitelist=True).exists() enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_id) + mode_is_verified = (enrollment_mode == GeneratedCertificate.MODES.verified) + user_is_verified = SoftwareSecurePhotoVerification.user_is_verified(student) + user_is_reverified = SoftwareSecurePhotoVerification.user_is_reverified_for_all(course_id, student) org = course_id.split('/')[0] course_num = course_id.split('/')[1] cert_mode = enrollment_mode - if ( - (enrollment_mode == GeneratedCertificate.MODES.verified) and - SoftwareSecurePhotoVerification.user_is_verified(student) and - SSPMidcourseReverification.user_is_reverified_for_all(course_id, student) - ): + if (mode_is_verified and user_is_verified and user_is_reverified): template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( org, course_num) - elif ( - (enrollment_mode == GeneratedCertificate.MODES.verified) and not - (SoftwareSecurePhotoVerification.user_is_verified(student)) and not - (SSPMidcourseReverification.user_is_reverified_for_all(course_id, student)) - ): + elif (mode_is_verified and not (user_is_verified and user_is_reverified)): template_pdf = "certificate-template-{0}-{1}.pdf".format( org, course_num) cert_mode = GeneratedCertificate.MODES.honor diff --git a/lms/djangoapps/verify_student/admin.py b/lms/djangoapps/verify_student/admin.py index fe7c2411a7..be3f5b24d3 100644 --- a/lms/djangoapps/verify_student/admin.py +++ b/lms/djangoapps/verify_student/admin.py @@ -3,4 +3,4 @@ from verify_student.models import SoftwareSecurePhotoVerification from verify_student.models import MidcourseReverificationWindow admin.site.register(SoftwareSecurePhotoVerification) -admin.site.register(MidcourseReverificationWindow) \ No newline at end of file +admin.site.register(MidcourseReverificationWindow) diff --git a/lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow.py b/lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow__add_field_softwaresecurephoto.py similarity index 90% rename from lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow.py rename to lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow__add_field_softwaresecurephoto.py index 5543247aed..92ec4fdb4c 100644 --- a/lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow.py +++ b/lms/djangoapps/verify_student/migrations/0002_auto__add_midcoursereverificationwindow__add_field_softwaresecurephoto.py @@ -17,11 +17,19 @@ class Migration(SchemaMigration): )) db.send_create_signal('verify_student', ['MidcourseReverificationWindow']) + # Adding field 'SoftwareSecurePhotoVerification.window' + db.add_column('verify_student_softwaresecurephotoverification', 'window', + self.gf('django.db.models.fields.related.ForeignKey')(to=orm['verify_student.MidcourseReverificationWindow'], null=True), + keep_default=False) + def backwards(self, orm): # Deleting model 'MidcourseReverificationWindow' db.delete_table('verify_student_midcoursereverificationwindow') + # Deleting field 'SoftwareSecurePhotoVerification.window' + db.delete_column('verify_student_softwaresecurephotoverification', 'window_id') + models = { 'auth.group': { @@ -77,14 +85,15 @@ class Migration(SchemaMigration): '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': "''", 'max_length': '255', 'db_index': 'True'}), + 'receipt_id': ('django.db.models.fields.CharField', [], {'default': "''", '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']"}) + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'window': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['verify_student.MidcourseReverificationWindow']", 'null': 'True'}) } } diff --git a/lms/djangoapps/verify_student/migrations/0003_auto__add_sspmidcoursereverification.py b/lms/djangoapps/verify_student/migrations/0003_auto__add_sspmidcoursereverification.py deleted file mode 100644 index 55720b1d04..0000000000 --- a/lms/djangoapps/verify_student/migrations/0003_auto__add_sspmidcoursereverification.py +++ /dev/null @@ -1,94 +0,0 @@ -# -*- coding: utf-8 -*- -import 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 'SSPMidcourseReverification' - db.create_table('verify_student_sspmidcoursereverification', ( - ('softwaresecurephotoverification_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['verify_student.SoftwareSecurePhotoVerification'], unique=True, primary_key=True)), - ('window', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['verify_student.MidcourseReverificationWindow'])), - )) - db.send_create_signal('verify_student', ['SSPMidcourseReverification']) - - - def backwards(self, orm): - # Deleting model 'SSPMidcourseReverification' - db.delete_table('verify_student_sspmidcoursereverification') - - - 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'}) - }, - 'verify_student.midcoursereverificationwindow': { - 'Meta': {'object_name': 'MidcourseReverificationWindow'}, - 'course_id': ('django.db.models.fields.CharField', [], {'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'}), - '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': "''", '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']"}) - }, - 'verify_student.sspmidcoursereverification': { - 'Meta': {'ordering': "['-created_at']", 'object_name': 'SSPMidcourseReverification', '_ormbases': ['verify_student.SoftwareSecurePhotoVerification']}, - 'softwaresecurephotoverification_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['verify_student.SoftwareSecurePhotoVerification']", 'unique': 'True', 'primary_key': 'True'}), - 'window': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['verify_student.MidcourseReverificationWindow']"}) - } - } - - complete_apps = ['verify_student'] \ No newline at end of file diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index a93362d9c9..b406cfa25b 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -23,7 +23,7 @@ import pytz import requests from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.urlresolvers import reverse from django.db import models from django.contrib.auth.models import User @@ -38,28 +38,33 @@ from verify_student.ssencrypt import ( log = logging.getLogger(__name__) -# Evidently South migrations complain a lot if you have a default set to uuid.uuid4, so -# I had to add this function to make South happy, see this for more: -# http://stackoverflow.com/questions/15041265/south-migrate-error-name-uuid-is-not-defined -# If anyone knows a happier solution, do let me know; otherwise I'll remove this comment -# after CR + def generateUUID(): - return str(uuid.uuid4) + return str(uuid.uuid4) + class MidcourseReverificationWindow(models.Model): """ Defines the start and end times for midcourse reverification for a particular course. - There can be many MidcourseReverificationWindows per course, but they should not - have overlapping time-ranges (i.e. Window2's start date should not be before Window1's - start date) (TODO: should the non-overlap constraint be explicitly enforced by the model?) + There can be many MidcourseReverificationWindows per course, but they cannot have + overlapping time ranges. This is enforced by this class's clean() method. """ # the course that this window is attached to - # TODO should this be a foreignkey? course_id = models.CharField(max_length=255, db_index=True) start_date = models.DateTimeField(default=None, null=True, blank=True) end_date = models.DateTimeField(default=None, null=True, blank=True) + def clean(self): + """ + Gives custom validation for the MidcourseReverificationWindow model. + Prevents overlapping windows for any particular course. + """ + query = MidcourseReverificationWindow.objects.filter(course_id=self.course_id) + for item in query: + if (self.start_date <= item.end_date) and (item.start_date <= self.end_date): + raise ValidationError('Reverification windows cannot overlap for a given course.') + @classmethod def window_open_for_course(cls, course_id): """ @@ -67,16 +72,16 @@ class MidcourseReverificationWindow(models.Model): """ now = datetime.now(pytz.UTC) - # We are assuming one window per course_id. TODO find out if this assumption is OK try: - window = cls.objects.get(course_id=course_id) + cls.objects.get( + course_id=course_id, + start_date__lte=now, + end_date__gte=now, + ) except(ObjectDoesNotExist): return False - if (window.start_date <= now <= window.end_date): - return True - else: - return False + return True @classmethod def get_window(cls, course_id, date): @@ -219,8 +224,6 @@ class PhotoVerification(StatusModel): # capturing it so that we can later query for the common problems. error_code = models.CharField(blank=True, max_length=50) - - class Meta: abstract = True ordering = ['-created_at'] @@ -239,21 +242,22 @@ class PhotoVerification(StatusModel): return allowed_date @classmethod - def user_is_verified(cls, user, earliest_allowed_date=None): + def user_is_verified(cls, user, earliest_allowed_date=None, window=None): """ Return whether or not a user has satisfactorily proved their - identity. Depending on the policy, this can expire after some period of - time, so a user might have to renew periodically. + identity wrt to the INITIAL verification. Depending on the policy, + this can expire after some period of time, so a user might have to renew periodically. """ return cls.objects.filter( user=user, status="approved", created_at__gte=(earliest_allowed_date - or cls._earliest_allowed_date()) + or cls._earliest_allowed_date()), + window=window ).exists() @classmethod - def user_has_valid_or_pending(cls, user, earliest_allowed_date=None): + def user_has_valid_or_pending(cls, user, earliest_allowed_date=None, window=None): """ Return whether the user has a complete verification attempt that is or *might* be good. This means that it's approved, been submitted, or would @@ -261,30 +265,34 @@ class PhotoVerification(StatusModel): submitted. It's basically any situation in which the user has signed off on the contents of the attempt, and we have not yet received a denial. """ - valid_statuses = ['must_retry', 'submitted', 'approved'] + if window: + valid_statuses = ['submitted', 'approved'] + else: + valid_statuses = ['must_retry', 'submitted', 'approved'] return cls.objects.filter( user=user, status__in=valid_statuses, created_at__gte=(earliest_allowed_date - or cls._earliest_allowed_date()) + or cls._earliest_allowed_date()), + window=window, ).exists() @classmethod - def active_for_user(cls, user): + def active_for_user(cls, user, window=None): """ - Return the most recent PhotoVerification that is marked ready (i.e. the + Return the most recent INITIAL PhotoVerification that is marked ready (i.e. the user has said they're set, but we haven't submitted anything yet). """ # This should only be one at the most, but just in case we create more # by mistake, we'll grab the most recently created one. - active_attempts = cls.objects.filter(user=user, status='ready').order_by('-created_at') + active_attempts = cls.objects.filter(user=user, status='ready', window=window).order_by('-created_at') if active_attempts: return active_attempts[0] else: return None @classmethod - def user_status(cls, user): + def user_status(cls, user, window=None): """ Returns the status of the user based on their past verification attempts @@ -297,32 +305,46 @@ class PhotoVerification(StatusModel): status = 'none' error_msg = '' - if cls.user_is_verified(user): + if cls.user_is_verified(user, window=window): status = 'approved' - elif cls.user_has_valid_or_pending(user): + + elif cls.user_has_valid_or_pending(user, window=window): # user_has_valid_or_pending does include 'approved', but if we are # here, we know that the attempt is still pending status = 'pending' + else: # we need to check the most recent attempt to see if we need to ask them to do # a retry try: - attempts = cls.objects.filter(user=user).order_by('-updated_at') + attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at') attempt = attempts[0] except IndexError: - return ('none', error_msg) + + # If no verification exists for a *midcourse* reverification, then that just + # means the student still needs to reverify. For *original* verifications, + # we return 'none' + if(window): + return('must_reverify', error_msg) + else: + return ('none', error_msg) + if attempt.created_at < cls._earliest_allowed_date(): return ('expired', error_msg) - # right now, this is the only state at which they must reverify. It - # may change later + # If someone is denied their original verification attempt, they can try to reverify. + # However, if a midcourse reverification is denied, that denial is permanent. if attempt.status == 'denied': - status = 'must_reverify' + if window is None: + status = 'must_reverify' + else: + status = 'denied' if attempt.error_msg: error_msg = attempt.parsed_error_msg() return (status, error_msg) + def parsed_error_msg(self): """ Sometimes, the error message we've received needs to be parsed into @@ -374,10 +396,6 @@ class PhotoVerification(StatusModel): self.status = "ready" self.save() - @status_before_must_be("must_retry", "ready", "submitted") - def submit(self): - raise NotImplementedError - @status_before_must_be("must_retry", "submitted", "approved", "denied") def approve(self, user_id=None, service=""): """ @@ -508,6 +526,12 @@ class SoftwareSecurePhotoVerification(PhotoVerification): 3. The encrypted photos are base64 encoded and stored in an S3 bucket that edx-platform does not have read access to. + + Note: this model handles both *inital* verifications (which you must perform + at the time you register for a verified cert), and *midcourse reverifications*. + To distinguish between the two, check the value of the property window: + intial verifications of a window of None, whereas midcourse reverifications + * must always be linked to a specific window*. """ # This is a base64.urlsafe_encode(rsa_encrypt(photo_id_aes_key), ss_pub_key) # So first we generate a random AES-256 key to encrypt our photo ID with. @@ -517,6 +541,43 @@ class SoftwareSecurePhotoVerification(PhotoVerification): IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds + window = models.ForeignKey(MidcourseReverificationWindow, db_index=True, null=True) + + @classmethod + def user_is_reverified_for_all(cls, course_id, user): + """ + Checks to see if the student has successfully reverified for all of the + mandatory re-verification windows associated with a course. + + This is used primarily by the certificate generation code... if the user is + not re-verified for all windows, then they cannot receive a certificate. + """ + all_windows = MidcourseReverificationWindow.objects.filter(course_id=course_id) + # if there are no windows for a course, then return True right off + if (not all_windows): + return True + + for window in all_windows: + try: + # The status of the most recent reverification for each window must be "approved" + # for a student to count as completely reverified + attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at') + attempt = attempts[0] + if attempt.status != "approved": + return False + except: + return False + + return True + + @classmethod + def original_verification(cls, user): + """ + Returns the most current SoftwareSecurePhotoVerification object associated with the user. + """ + query = cls.objects.filter(user=user, window=None).order_by('-updated_at') + return query[0] + @status_before_must_be("created") def upload_face_image(self, img_data): """ @@ -540,6 +601,19 @@ class SoftwareSecurePhotoVerification(PhotoVerification): s3_key = self._generate_s3_key("face") s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key)) + @status_before_must_be("created") + def fetch_photo_id_image(self): + """ + Find the user's photo ID image, which was submitted with their original verification. + The image has already been encrypted and stored in s3, so we just need to find that + location + """ + if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'): + return + + self.photo_id_key = self.original_verification(self.user).photo_id_key + self.save() + @status_before_must_be("created") def upload_photo_id_image(self, img_data): """ @@ -715,6 +789,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): return header_txt + "\n\n" + body_txt + def send_request(self): """ Assembles a submission to Software Secure and sends it via HTTPS. @@ -745,210 +820,3 @@ class SoftwareSecurePhotoVerification(PhotoVerification): log.debug("Return message:\n\n{}\n\n".format(response.text)) return response - -class SSPMidcourseReverification(SoftwareSecurePhotoVerification): - """ - Model to re-verify identity using a service provided by Software Secure. - - As of now, it's inheriting a great deal of logic from both `PhotoVerification` - and `SoftwareSecurePhotoVerification`, but it might make more sense to just inherit - from `PhotoVerification`, or maybe not at all... a lot of classes had to get stomped/ - rewritten. Will think about this during CR. - - TODO: another important thing to note during CR: right now we're assuming there's one - window per (user, course) combo. This is UNTRUE in general (there can be many windows - per course, user pair), but we only need ONE window per (user, course) to launch. - Note the user_status methods in particular make this assumption. - - Fix this if time permits... - """ - window = models.ForeignKey(MidcourseReverificationWindow, db_index=True) - - @classmethod - def user_is_reverified_for_all(self, course_id, user): - """ - Checks to see if the student has successfully reverified for all of the - mandatory re-verification windows associated with a course. - - This is used primarily by the certificate generation code... if the user is - not re-verified for all windows, then they cannot receive a certificate. - """ - all_windows = MidcourseReverificationWindow.objects.filter(course_id=course_id) - # TODO check on this - # if there are no windows for a course, then return True right off - if (not all_windows): - return True - for window in all_windows: - try: - # There should be one and only one reverification object per (user, window) - # and the status of that object should be approved - if cls.objects.get(window=window, user=user).status != "approved": - return False - except: - return False - return True - - # TODO does this actually get the original_verification? pretty sure I need to search by date - def original_verification(self): - """ - Returns the most current SoftwareSecurePhotoVerification object associated with the user. - """ - return (SoftwareSecurePhotoVerification.objects.get(user=self.user)) - - # TODO could just call original_verification's _generate_s3_key? - def _generate_original_s3_key(self, prefix): - - #Generates a key into the S3 bucket where the original verification is stored - - #Example: face/4dd1add9-6719-42f7-bea0-115c008c4fca - - conn = S3Connection( - settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["AWS_ACCESS_KEY"], - settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["AWS_SECRET_KEY"] - ) - bucket = conn.get_bucket(settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["S3_BUCKET"]) - - key = Key(bucket) - key.key = "{}/{}".format(prefix, self.original_verification().receipt_id) - - return key - - @status_before_must_be("created") - def fetch_photo_id_image(self): - - #Find the user's photo ID image, which was submitted with their original verification. - #The image has already been encrypted and stored in s3, so we just need to find that - #location - - - if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'): - return - - old_s3_key = self._generate_original_s3_key("face") - new_s3_key = self._generate_s3_key("face") - - original_photo_id = old_s3_key.get_contents_as_string() - - # Unlike upload_face_image, we don't need to encrypt and encode with AES, since that - # was already done when we uploaded it for the initial verification - new_s3_key.set_contents_from_string(original_photo_id) - self.photo_id_key = self.original_verification().photo_id_key - self.save() - - # we replace_photo_id_image with fetch_photo_id_image - @status_before_must_be("created") - def upload_photo_id_image(self, img_data): - raise NotImplementedError - - # TODO right now this does nothing but return must_reverify, fix!!! - @classmethod - def get_status_for_window(cls, user, window): - """ - Returns the status of the user based on their past verification attempts - - If no such verification exists, returns 'must_reverify' - If verification has expired, returns 'expired' --> does this exist for windows? - If the verification has been approved, returns 'approved' - If the verification process is still ongoing, returns 'pending' - If the verification has been denied and the user must resubmit photos, returns 'must_reverify' - """ - reverify_attempt = cls.objects.filter(user=user, window=window) - return "must_reverify" - #if not reverify_attempt: - # return "must_reverify" - #else: - #return reverify_attempt.STATUS - - # can't just inherit the old user_status function, because it's insufficiently specific - # reverifications are unique for a particular (user, window) pair, not just on user - # TODO: Note that a lot of the user_status related stuff is having to get overwritten. - # Does it still make sense to inherit from our parent object(s)? - @classmethod - def user_status(cls, user): - raise NotImplementedError - - @classmethod - def user_status(cls, user, course_id): - """ - Returns the status of the user based on their past verification attempts - - If no such verification exists, returns 'none' - If verification has expired, returns 'expired' - If the verification has been approved, returns 'approved' - If the verification process is still ongoing, returns 'pending' - If the verification has been denied and the user must resubmit photos, returns 'must_reverify' - """ - status = 'none' - error_msg = '' - - if cls.user_is_verified(user): - status = 'approved' - elif cls.user_has_valid_or_pending(user): - # user_has_valid_or_pending does include 'approved', but if we are - # here, we know that the attempt is still pending - status = 'pending' - else: - # we need to check the most recent attempt to see if we need to ask them to do - # a retry - try: - attempts = cls.objects.filter(user=user).order_by('-updated_at') - attempt = attempts[0] - # this is the change for SSPMidcoursePhotoVerification objects - # if there is no verification, we look up course_id, via window, and find out if the user has a verified enrollment - # if verified enrolled in course but no verification: must_reverify - # if not verified enrollment: none - except IndexError: - if CourseEnrollment.objects.filter(user=user, course_id=course_id, mode="verified").exists: - return ('must_reverify', error_msg) - else: - return('none', error_msg) - if attempt.created_at < cls._earliest_allowed_date(): - return ('expired', error_msg) - - # right now, this is the only state at which they must reverify. It - # may change later - if attempt.status == 'denied': - status = 'must_reverify' - if attempt.error_msg: - error_msg = attempt.parsed_error_msg() - - return (status, error_msg) - - # can't inherit - @classmethod - def user_is_verified(cls, user): - raise NotImplementedError - - @classmethod - def user_is_verified(cls, user, course_id): - return cls.objects.filter( - user=user, status="approved", window__course_id=course_id - ).exists() - - # can't inherit - @classmethod - def user_has_valid_or_pending(cls, user): - return NotImplementedError - - # changing this method? - @classmethod - def user_has_valid_or_pending(cls, user, course_id): - valid_statuses = ['submitted', 'approved'] - return cls.objects.filter( - user=user, - window__course_id=course_id, - status__in=valid_statuses, - ).exists() - - # can't inherit - @classmethod - def active_for_user(cls, user): - return NotImplementedError - - @classmethod - def active_for_user(cls, user, course_id): - active_attempts = cls.objects.filter(user=user, status='ready', window__course_id=course_id) - if active_attempts: - return active_attempts[0] - else: - return None diff --git a/lms/djangoapps/verify_student/tests/factories.py b/lms/djangoapps/verify_student/tests/factories.py new file mode 100644 index 0000000000..57f0125eb0 --- /dev/null +++ b/lms/djangoapps/verify_student/tests/factories.py @@ -0,0 +1,19 @@ +""" +verify_student factories +""" +from verify_student.models import MidcourseReverificationWindow +from factory.django import DjangoModelFactory +import pytz +from datetime import timedelta, datetime + + +# Factories don't have __init__ methods, and are self documenting +# pylint: disable=W0232 +class MidcourseReverificationWindowFactory(DjangoModelFactory): + """ Creates a generic MidcourseReverificationWindow. """ + FACTORY_FOR = MidcourseReverificationWindow + + course_id = u'MITx/999/Robot_Super_Course' + # By default this factory creates a window that is currently open + start_date = datetime.now(pytz.UTC) - timedelta(days=100) + end_date = datetime.now(pytz.UTC) + timedelta(days=100) diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index fd2b767859..26c4149dc3 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -1,18 +1,26 @@ # -*- coding: utf-8 -*- -from datetime import timedelta +from datetime import timedelta, datetime import json +from xmodule.modulestore.tests.factories import CourseFactory from nose.tools import ( assert_in, assert_is_none, assert_equals, assert_not_equals, assert_raises, assert_true, assert_false ) from mock import MagicMock, patch +import pytz from django.test import TestCase +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from django.test.utils import override_settings from django.conf import settings import requests import requests.exceptions from student.tests.factories import UserFactory -from verify_student.models import SoftwareSecurePhotoVerification, VerificationException +from verify_student.models import ( + SoftwareSecurePhotoVerification, VerificationException, + MidcourseReverificationWindow, +) +from verify_student.tests.factories import MidcourseReverificationWindowFactory from util.testing import UrlResetMixin import verify_student.models @@ -208,6 +216,23 @@ class TestPhotoVerification(TestCase): return attempt + def test_fetch_photo_id_image(self): + user = UserFactory.create() + orig_attempt = SoftwareSecurePhotoVerification(user=user, window=None) + orig_attempt.save() + + old_key = orig_attempt.photo_id_key + + window = MidcourseReverificationWindowFactory( + course_id="ponies", + start_date=datetime.now(pytz.utc) - timedelta(days=5), + end_date=datetime.now(pytz.utc) + timedelta(days=5) + ) + new_attempt = SoftwareSecurePhotoVerification(user=user, window=window) + new_attempt.save() + new_attempt.fetch_photo_id_image() + assert_equals(new_attempt.photo_id_key, old_key) + def test_submissions(self): """Test that we set our status correctly after a submission.""" # Basic case, things go well. @@ -362,3 +387,146 @@ class TestPhotoVerification(TestCase): attempt.error_msg = msg parsed_error_msg = attempt.parsed_error_msg() self.assertEquals(parsed_error_msg, "There was an error verifying your ID photos.") + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestMidcourseReverificationWindow(TestCase): + """ Tests for MidcourseReverificationWindow objects """ + def setUp(self): + self.course_id = "MITx/999/Robot_Super_Course" + CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + + def test_window_open_for_course(self): + # Should return False if no windows exist for a course + self.assertFalse(MidcourseReverificationWindow.window_open_for_course(self.course_id)) + + # Should return False if a window exists, but it's not in the current timeframe + MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.utc) - timedelta(days=10), + end_date=datetime.now(pytz.utc) - timedelta(days=5) + ) + self.assertFalse(MidcourseReverificationWindow.window_open_for_course(self.course_id)) + + # Should return True if a non-expired window exists + MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.utc) - timedelta(days=3), + end_date=datetime.now(pytz.utc) + timedelta(days=3) + ) + self.assertTrue(MidcourseReverificationWindow.window_open_for_course(self.course_id)) + + def test_get_window(self): + # if no window exists, returns None + self.assertIsNone(MidcourseReverificationWindow.get_window(self.course_id, datetime.now(pytz.utc))) + + # we should get the expected window otherwise + window_valid = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.utc) - timedelta(days=3), + end_date=datetime.now(pytz.utc) + timedelta(days=3) + ) + self.assertEquals( + window_valid, + MidcourseReverificationWindow.get_window(self.course_id, datetime.now(pytz.utc)) + ) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) +@patch('verify_student.models.S3Connection', new=MockS3Connection) +@patch('verify_student.models.Key', new=MockKey) +@patch('verify_student.models.requests.post', new=mock_software_secure_post) +class TestMidcourseReverification(TestCase): + def setUp(self): + self.course_id = "MITx/999/Robot_Super_Course" + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.user = UserFactory.create() + + def test_user_is_reverified_for_all(self): + + # if there are no windows for a course, this should return True + self.assertTrue(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + # first, make three windows + window1 = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=15), + end_date=datetime.now(pytz.UTC) - timedelta(days=13), + ) + + window2 = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=10), + end_date=datetime.now(pytz.UTC) - timedelta(days=8), + ) + + window3 = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=5), + end_date=datetime.now(pytz.UTC) - timedelta(days=3), + ) + + # make two SSPMidcourseReverifications for those windows + attempt1 = SoftwareSecurePhotoVerification( + status="approved", + user=self.user, + window=window1 + ) + attempt1.save() + + attempt2 = SoftwareSecurePhotoVerification( + status="approved", + user=self.user, + window=window2 + ) + attempt2.save() + + # should return False because only 2 of 3 windows have verifications + self.assertFalse(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + attempt3 = SoftwareSecurePhotoVerification( + status="must_retry", + user=self.user, + window=window3 + ) + attempt3.save() + + # should return False because the last verification exists BUT is not approved + self.assertFalse(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + attempt3.status = "approved" + attempt3.save() + + # should now return True because all windows have approved verifications + self.assertTrue(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + def test_original_verification(self): + orig_attempt = SoftwareSecurePhotoVerification(user=self.user) + orig_attempt.save() + window = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=15), + end_date=datetime.now(pytz.UTC) - timedelta(days=13), + ) + midcourse_attempt = SoftwareSecurePhotoVerification(user=self.user, window=window) + self.assertEquals(midcourse_attempt.original_verification(user=self.user), orig_attempt) + + def test_user_has_valid_or_pending(self): + window = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=15), + end_date=datetime.now(pytz.UTC) - timedelta(days=13), + ) + + attempt = SoftwareSecurePhotoVerification(status="must_retry", user=self.user, window=window) + attempt.save() + + assert_false(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user=self.user, window=window)) + + attempt.status = "approved" + attempt.save() + assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user=self.user, window=window)) + + def test_active_for_user(self): + pass diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index c14f41d87b..a6dd07b489 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -21,9 +21,11 @@ from django.core.exceptions import ObjectDoesNotExist from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory +from student.models import CourseEnrollment from course_modes.models import CourseMode from verify_student.views import render_to_response from verify_student.models import SoftwareSecurePhotoVerification +from verify_student.tests.factories import MidcourseReverificationWindowFactory def mock_render_to_response(*args, **kwargs): @@ -80,6 +82,8 @@ class TestReverifyView(TestCase): def setUp(self): self.user = UserFactory.create(username="rusty", password="test") self.client.login(username="rusty", password="test") + self.course_id = "MITx/999/Robot_Super_Course" + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') @patch('verify_student.views.render_to_response', render_mock) def test_reverify_get(self): @@ -110,3 +114,61 @@ class TestReverifyView(TestCase): self.assertIsNotNone(verification_attempt) except ObjectDoesNotExist: self.fail('No verification object generated') + self.assertIn('photo_reverification', template) + self.assertTrue(context['error']) + + @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) + def test_reverify_post_success(self): + url = reverse('verify_student_reverify') + response = self.client.post(url, {'face_image': ',', + 'photo_id_image': ','}) + self.assertEquals(response.status_code, 302) + try: + verification_attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user) + self.assertIsNotNone(verification_attempt) + except ObjectDoesNotExist: + self.fail('No verification object generated') + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestMidCourseReverifyView(TestCase): + def setUp(self): + self.user = UserFactory.create(username="rusty", password="test") + self.client.login(username="rusty", password="test") + self.course_id = 'Robot/999/Test_Course' + CourseFactory.create(org='Robot', number='999', display_name='Test Course') + + @patch('verify_student.views.render_to_response', render_mock) + def test_midcourse_reverify_get(self): + url = reverse('verify_student_midcourse_reverify', + kwargs={"course_id": self.course_id}) + response = self.client.get(url) + self.assertEquals(response.status_code, 200) + ((_template, context), _kwargs) = render_mock.call_args + self.assertFalse(context['error']) + + @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) + def test_midcourse_reverify_post_success(self): + url = reverse('verify_student_midcourse_reverify', kwargs={'course_id': self.course_id}) + response = self.client.post(url, {'face_image': ','}) + self.assertEquals(response.status_code, 302) + try: + verification_attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user) + self.assertIsNotNone(verification_attempt) + except ObjectDoesNotExist: + self.fail('No verification object generated') + + # TODO make this test more detailed + @patch('verify_student.views.render_to_response', render_mock) + def test_midcourse_reverify_dash(self): + url = reverse('verify_student_midcourse_reverify_dash') + response = self.client.get(url) + # not enrolled in any courses + self.assertEquals(response.status_code, 200) + + enrollment = CourseEnrollment.get_or_create_enrollment(self.user, self.course_id) + enrollment.update_enrollment(mode="verified", is_active=True) + MidcourseReverificationWindowFactory(course_id=self.course_id) + response = self.client.get(url) + # enrolled in a verified course, and the window is open + self.assertEquals(response.status_code, 200) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index d972441d4d..283d03fa64 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -30,9 +30,10 @@ from shoppingcart.processors.CyberSource import ( get_signed_purchase_params, get_purchase_endpoint ) from verify_student.models import ( - SoftwareSecurePhotoVerification, MidcourseReverificationWindow, SSPMidcourseReverification + SoftwareSecurePhotoVerification, MidcourseReverificationWindow, ) import ssencrypt +from xmodule.modulestore.exceptions import ItemNotFoundError log = logging.getLogger(__name__) @@ -326,6 +327,7 @@ class ReverifyView(View): } return render_to_response("verify_student/photo_reverification.html", context) + class MidCourseReverifyView(View): """ The mid-course reverification view. @@ -341,10 +343,16 @@ class MidCourseReverifyView(View): """ display this view """ + course = course_from_id(course_id) context = { "user_full_name": request.user.profile.name, "error": False, "course_id": course_id, + "course_name": course.display_name_with_default, + "course_org": course.display_org_with_default, + "course_num": course.display_number_with_default, + "reverify": True, + } return render_to_response("verify_student/midcourse_photo_reverification.html", context) @@ -356,7 +364,7 @@ class MidCourseReverifyView(View): try: # TODO look at this more carefully! #1 testing candidate now = datetime.datetime.now(UTC) - attempt = SSPMidcourseReverification(user=request.user, window=MidcourseReverificationWindow.get_window(course_id, now)) + attempt = SoftwareSecurePhotoVerification(user=request.user, window=MidcourseReverificationWindow.get_window(course_id, now)) b64_face_image = request.POST['face_image'].split(",")[1] attempt.upload_face_image(b64_face_image.decode('base64')) @@ -376,7 +384,12 @@ class MidCourseReverifyView(View): } return render_to_response("verify_student/midcourse_photo_reverification.html", context) + def midcourse_reverify_dash(_request): + """ + Shows the "course reverification dashboard", which displays the reverification status (must reverify, + pending, approved, failed, etc) of all courses in which a student has a verified enrollment. + """ # TODO same comment as in student/views.py: need to factor out this functionality user = _request.user course_enrollment_pairs = [] @@ -397,13 +410,13 @@ def midcourse_reverify_dash(_request): "must_reverify" ) ) - prompt_midcourse_reverify = True context = { "user_full_name": _request.user.profile.name, "reverify_course_data": reverify_course_data, } return render_to_response("verify_student/midcourse_reverify_dash.html", context) + @login_required def reverification_submission_confirmation(_request): """ @@ -411,6 +424,7 @@ def reverification_submission_confirmation(_request): """ return render_to_response("verify_student/reverification_confirmation.html") + @login_required def midcourse_reverification_confirmation(_request): """ diff --git a/lms/static/js/verify_student/photocapturebasic2.js b/lms/static/js/verify_student/photocapturebasic2.js index 0ca03aca4d..7bf34582cd 100644 --- a/lms/static/js/verify_student/photocapturebasic2.js +++ b/lms/static/js/verify_student/photocapturebasic2.js @@ -1,4 +1,3 @@ -// TODO diff this against photocapture.js, see if I actually needed a whole honking new file var onVideoFail = function(e) { if(e == 'NO_DEVICES_FOUND') { $('#no-webcam').show(); @@ -25,7 +24,7 @@ var submitReverificationPhotos = function() { name: 'face_image', value: $("#face_image")[0].src, }).appendTo("#reverify_form"); - + // there is a change here $("#reverify_form").submit(); } @@ -48,6 +47,7 @@ var submitToPaymentProcessing = function() { "course_id" : course_id, "contribution": contribution, "face_image" : $("#face_image")[0].src, + // there is a change here }, function(data) { for (prop in data) { diff --git a/lms/static/sass/application-extend1.scss.mako b/lms/static/sass/application-extend1.scss.mako index 310abf9f2d..cb993e14b6 100644 --- a/lms/static/sass/application-extend1.scss.mako +++ b/lms/static/sass/application-extend1.scss.mako @@ -12,8 +12,8 @@ // base - utilities @import 'base/reset'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index 9473a41e4a..cfb6a1b698 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -12,8 +12,8 @@ // base - utilities @import 'base/reset'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- @@ -41,6 +41,7 @@ // base - elements @import 'elements/typography'; @import 'elements/controls'; +@import 'elements/system-feedback'; // base - specific views @import 'views/verification'; diff --git a/lms/static/sass/application.scss.mako b/lms/static/sass/application.scss.mako index 7d6da444ce..5365ec52ac 100644 --- a/lms/static/sass/application.scss.mako +++ b/lms/static/sass/application.scss.mako @@ -11,8 +11,8 @@ // base - utilities @import 'base/reset'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index 5a5a4fde94..22a1d96dbd 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -54,6 +54,30 @@ // ==================== + +// extends - UI - used for page/view-level wrappers (for centering/grids) +%ui-wrapper { + @include clearfix(); + @include box-sizing(border-box); + width: 100%; +} + +// extends - UI - window +%ui-window { + @include clearfix(); + border-radius: 3px; + box-shadow: 0 1px 2px 1px $shadow-l1; + margin-bottom: $baseline; + border: 1px solid $light-gray; + background: $white; +} + +// extends - UI archetypes - well +%ui-well { + box-shadow: inset 0 1px 2px 1px $shadow-l1; + padding: ($baseline*0.75) $baseline; +} + // extends - UI - visual link %ui-fake-link { cursor: pointer; diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 5dda2071b5..dff281e1d5 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -308,3 +308,8 @@ $video-thumb-url: '../images/courses/video-thumb.jpg'; $f-serif: 'Bree Serif', Georgia, Cambria, 'Times New Roman', Times, serif; $f-sans-serif: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif; $f-monospace: 'Bitstream Vera Sans Mono', Consolas, Courier, monospace; + +// SPLINT: colors + +$msg-bg: $action-primary-bg; + diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 4bd8cfe4bd..2cdd1c2031 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -2,8 +2,8 @@ @import 'base/reset'; @import 'base/font_face'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- diff --git a/lms/static/sass/elements/_system-feedback.scss b/lms/static/sass/elements/_system-feedback.scss new file mode 100644 index 0000000000..5ee7cc591b --- /dev/null +++ b/lms/static/sass/elements/_system-feedback.scss @@ -0,0 +1,74 @@ +// lms - elements - system feedback +// ==================== + +// messages + +// UI : message +.wrapper-msg { + box-shadow: 0 0 5px $action-primary-shadow inset; + margin-bottom: ($baseline*1.5); + padding: $baseline ($baseline*1.5); + background: $action-primary-bg; + + .msg { + @include clearfix(); + max-width: grid-width(12); + min-width: 760px; + width: flex-grid(12); + margin: 0 auto; + } + + .msg-content, + .msg-icon { + display: inline-block; + vertical-align: middle; + } + + .msg-content { + width: flex-grid(10,12); + + .title { + @extend %t-title5; + @extend %t-weight4; + margin-bottom: ($baseline/4); + color: $white; + text-transform: none; + letter-spacing: 0; + } + + .copy { + @extend %t-copy-sub1; + color: $white; + + p { // nasty reset + @extend %t-copy-sub1; + color: $white; + } + } + } + + .has-actions { + + .msg-content { + width: flex-grid(10,12); + } + + .nav-actions { + width: flex-grid(2,12); + display: inline-block; + vertical-align: middle; + + .action-primary { + @extend %btn-primary-green; + } + } + } + + +} + +// prompts + +// notifications + +// alerts diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 9bd1274928..1cde75de14 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -1,87 +1,6 @@ // lms - views - verification flow // ==================== -// MISC: extends - type -// application: canned headings -%hd-lv1 { - @extend %t-title1; - @extend %t-weight1; - color: $m-gray-d4; - margin: 0 0 ($baseline*2) 0; -} - -%hd-lv2 { - @extend %t-title4; - @extend %t-weight1; - margin: 0 0 ($baseline*0.75) 0; - border-bottom: 1px solid $m-gray-l4; - padding-bottom: ($baseline/2); - color: $m-gray-d4; -} - -%hd-lv3 { - @extend %t-title6; - @extend %t-weight4; - margin: 0 0 ($baseline/4) 0; - color: $m-gray-d4; -} - -%hd-lv4 { - @extend %t-title6; - @extend %t-weight2; - margin: 0 0 $baseline 0; - color: $m-gray-d4; -} - -%hd-lv5 { - @extend %t-title7; - @extend %t-weight4; - margin: 0 0 ($baseline/4) 0; - color: $m-gray-d4; -} - -// application: canned copy -%copy-base { - @extend %t-copy-base; - color: $m-gray-d2; -} - -%copy-lead1 { - @extend %t-copy-lead2; - color: $m-gray; -} - -%copy-detail { - @extend %t-copy-sub1; - @extend %t-weight3; - color: $m-gray-d1; -} - -%copy-metadata { - @extend %t-copy-sub2; - color: $m-gray-d1; - - - %copy-metadata-value { - @extend %t-weight2; - } - - %copy-metadata-value { - @extend %t-weight4; - } -} - -// application: canned links -%copy-link { - border-bottom: 1px dotted transparent; - - &:hover, &:active { - border-color: $link-color-d1; - } -} - -// ==================== - // MISC: extends - button %btn-verify-primary { @extend %btn-primary-green; @@ -89,26 +8,6 @@ // ==================== -// MISC: extends - UI - window -%ui-window { - @include clearfix(); - border-radius: ($baseline/10); - box-shadow: 0 1px 2px 1px $shadow-l1; - margin-bottom: $baseline; - border: 1px solid $m-gray-l3; - background: $white; -} - -// ==================== - -// MISC: extends - UI - well -%ui-well { - box-shadow: inset 0 1px 2px 1px $shadow-l1; - padding: ($baseline*0.75) $baseline; -} - -// ==================== - // MISC: expandable UI .is-expandable { @@ -153,7 +52,8 @@ // ==================== // VIEW: all verification steps -.verification-process { +.verification-process, +.midcourse-reverification-process { // reset: box-sizing (making things so right its scary) * { @@ -1894,6 +1794,335 @@ } } } + + // VIEW: midcourse re-verification + &.midcourse-reverification-process { + + // step-dash + .action-reverify { + @extend %btn-verify-primary; + padding: ($baseline/2) ($baseline*0.75); + } + + .reverification-table { + width: 100%; + + th { + display: none; + } + + th, + td { + padding: ($baseline/2) 0; + text-align: left; + border-bottom: 1px solid $light-gray; + } + + .course-name { + @extend %t-title5; + display: block; + font-weight: bold; + } + + .deadline { + @extend %copy-detail; + display: block; + } + } + + + .wrapper-reverification-help { + margin-top: $baseline*2; + + .faq-item { + display: inline-block; + vertical-align: top; + width: flex-grid(4,12); + padding-right: $baseline; + + &:last-child { + padding-right: 0; + } + + .faq-answer { + @extend %t-copy-sub1; + } + } + } + + // step-photos + .wrapper-task { + @include clearfix(); + width: flex-grid(12,12); + margin: $baseline 0; + + .wrapper-help { + float: right; + width: flex-grid(6,12); + padding: 0 $baseline; + + .help { + margin-bottom: ($baseline*1.5); + + &:last-child { + margin-bottom: 0; + } + + .title { + @extend %hd-lv3; + } + + .copy { + @extend %copy-detail; + } + + .example { + color: $m-gray-l2; + } + + // help - general list + .list-help { + margin-top: ($baseline/2); + color: $black; + + .help-item { + margin-bottom: ($baseline/4); + border-bottom: 1px solid $m-gray-l4; + padding-bottom: ($baseline/4); + + &:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: 0; + } + } + + .help-item-emphasis { + @extend %t-weight4; + } + } + + // help - faq + .list-faq { + margin-bottom: $baseline; + } + } + } + + .task { + @extend %ui-window; + float: left; + width: flex-grid(6,12); + margin-right: flex-gutter(); + } + + .controls { + padding: ($baseline*0.75) $baseline; + background: $m-gray-l4; + + .list-controls { + position: relative; + } + + .control { + position: absolute; + + .action { + @extend %btn-primary-blue; + padding: ($baseline/2) ($baseline*0.75); + + *[class^="icon-"] { + @extend %t-icon4; + padding: ($baseline*.25) ($baseline*.5); + display: block; + } + } + + // STATE: hidden + &.is-hidden { + visibility: hidden; + } + + // STATE: shown + &.is-shown { + visibility: visible; + } + + // STATE: approved + &.approved { + + .action { + @extend %btn-verify-primary; + padding: ($baseline/2) ($baseline*0.75); + } + } + } + + // control - redo + .control-redo { + position: absolute; + left: ($baseline/2); + } + + // control - take/do + .control-do { + left: 45%; + } + + // control - approve + .control-approve { + position: absolute; + right: ($baseline/2); + } + } + + .msg { + @include clearfix(); + margin-top: ($baseline*2); + + .copy { + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .list-actions { + position: relative; + top: -($baseline/2); + float: left; + width: flex-grid(4,12); + text-align: right; + + .action-retakephotos a { + @extend %btn-primary-blue; + @include font-size(14); + padding: ($baseline/2) ($baseline*.75); + } + } + } + + .msg-followup { + border-top: ($baseline/10) solid $m-gray-t0; + padding-top: $baseline; + } + } + + + .review-task { + margin-bottom: ($baseline*1.5); + padding: ($baseline*0.75) $baseline; + border-radius: ($baseline/10); + background: $m-gray-l4; + + &:last-child { + margin-bottom: 0; + } + + > .title { + @extend %hd-lv3; + } + + .copy { + @extend %copy-base; + + strong { + @extend %t-weight5; + color: $m-gray-d4; + } + } + } + + + // individual task - name + .review-task-name { + @include clearfix(); + + .copy { + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .list-actions { + position: relative; + top: -($baseline); + float: left; + width: flex-grid(4,12); + text-align: right; + + .action-editname a { + @extend %btn-primary-blue; + @include font-size(14); + padding: ($baseline/2) ($baseline*.75); + } + } + } + + .nav-wizard { + padding: ($baseline*.75) $baseline; + + .prompt-verify { + float: left; + width: flex-grid(6,12); + margin: 0 flex-gutter() 0 0; + + .title { + @extend %hd-lv4; + margin-bottom: ($baseline/4); + } + + .copy { + @extend %t-copy-sub1; + @extend %t-weight3; + } + + .list-actions { + margin-top: ($baseline/2); + } + + .action-verify label { + @extend %t-copy-sub1; + } + } + + .wizard-steps { + margin-top: ($baseline/2); + + .wizard-step { + margin-right: flex-gutter(); + display: inline-block; + vertical-align: middle; + + &:last-child { + margin-right: 0; + } + } + } + } + + + .modal { + + fieldset { + margin-top: $baseline; + } + + .close-modal { + @include font-size(24); + color: $m-blue-d3; + + &:hover, &:focus { + color: $m-blue-d1; + border: none; + } + } + } + + } + + + + } // ==================== diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 397c73fff0..dc75b3c59b 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -152,14 +152,17 @@ -
- - %if prompt_midcourse_reverify: -
+ +%if reverify_course_data: +
+
<%include file='dashboard/_dashboard_prompt_midcourse_reverify.html' /> -
- % endif + +
+% endif + +
%if message:
diff --git a/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html index b0c3bb8fa0..51e0da3427 100644 --- a/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html +++ b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html @@ -2,12 +2,24 @@ <%! from django.core.urlresolvers import reverse %> -% if prompt_midcourse_reverify: -

${_("You need to re-verify to continue")}

-% for course_id, course_name, date, status in reverify_course_data: -

- ${_('To continue in the verified track in {course_name}, you need to re-verify your identity by {date}.').format(course_name=course_name, date=date)} - Click here to re-verify. -

+% if reverify_course_data: +
+
+

${_("You need to re-verify to continue")}

+ % for course_id, course_name, date, status in reverify_course_data: +
+

+ ${_('To continue in the verified track in {course_name}, you need to re-verify your identity by {date}.').format(course_name=course_name, date=date)} +

+
+
+ +
+ % endfor %endif diff --git a/lms/templates/verify_student/_reverification_support.html b/lms/templates/verify_student/_reverification_support.html index 44bf0c89d8..4e6bda98ff 100644 --- a/lms/templates/verify_student/_reverification_support.html +++ b/lms/templates/verify_student/_reverification_support.html @@ -6,12 +6,7 @@
  • ${_("Why Do I Need to Re-Verify?")}

    -

    ${_("There was a problem with your original verification. To make sure that your identity is correctly associated with your course progress, we need to retake your photo and a photo of your identification document. If you don't have a valid identification document, contact {link_start}{support_email}{link_end}.").format( - support_email=settings.DEFAULT_FEEDBACK_EMAIL, - link_start=u''.format( - address=settings.DEFAULT_FEEDBACK_EMAIL, - subject_line=_('Problem with ID re-verification')), - link_end=u'')}

    +

    ${_("At key points in a course, the professor will ask you to re-verify your identity. We will send the new photo to be matched up with the photo of the original ID you submitted when you signed up for the course.")}

  • diff --git a/lms/templates/verify_student/_verification_header.html b/lms/templates/verify_student/_verification_header.html index 80a1e939b0..682652b870 100644 --- a/lms/templates/verify_student/_verification_header.html +++ b/lms/templates/verify_student/_verification_header.html @@ -4,6 +4,8 @@

    %if upgrade: ${_("You are upgrading your registration for")} + %elif reverify: + ${_("You are re-verifying for")} %else: ${_("You are registering for")} %endif @@ -19,6 +21,8 @@ %if upgrade: ${_("Upgrading to:")} ${_("ID Verified")} + %elif reverify: + ${_("Re-verifying for:")} ${_("ID Verified")} %else: ${_("Registering as: ")} ${_("ID Verified")} %endif diff --git a/lms/templates/verify_student/midcourse_photo_reverification.html b/lms/templates/verify_student/midcourse_photo_reverification.html index 98c464329d..ac06e65234 100644 --- a/lms/templates/verify_student/midcourse_photo_reverification.html +++ b/lms/templates/verify_student/midcourse_photo_reverification.html @@ -3,8 +3,8 @@ <%inherit file="../main.html" /> <%namespace name='static' file='/static_content.html'/> -<%block name="bodyclass">register verification-process is-not-verified step-photos -<%block name="title">${_("Re-Verification")} +<%block name="bodyclass">midcourse-reverification-process is-not-verified step-photos register +<%block name="title">${_("Re-Verify | edX")} <%block name="js_extra"> @@ -56,20 +56,13 @@
    -
    -
    -
    -

    ${_("You are re-verifying your identity")}

    -
    - - -
    -
    -
    -