Commit 47b81c92 by Will Daly

Allow users to submit initial verification at in-course checkpoints.

* Combine verification and reverification photo submission end-points.
* Combine verification and reverification Backbone models.
* Allow deletion of verification status in Django admin.
* Initial verification from a checkpoint is associated with the checkpoint.
* Fix critical bug in which an invalid link was sent to Software Secure
  for the photo ID image of reverification attempts.
parent cb217563
from ratelimitbackend import admin from ratelimitbackend import admin
from config_models.admin import ConfigurationModelAdmin
from verify_student.models import ( from verify_student.models import (
SoftwareSecurePhotoVerification, SoftwareSecurePhotoVerification,
InCourseReverificationConfiguration,
VerificationStatus, VerificationStatus,
SkippedReverification, SkippedReverification,
) )
...@@ -40,10 +38,6 @@ class VerificationStatusAdmin(admin.ModelAdmin): ...@@ -40,10 +38,6 @@ class VerificationStatusAdmin(admin.ModelAdmin):
return self.readonly_fields + ('status', 'checkpoint', 'user', 'response', 'error') return self.readonly_fields + ('status', 'checkpoint', 'user', 'response', 'error')
return self.readonly_fields return self.readonly_fields
def has_delete_permission(self, request, obj=None):
"""The verification status table is append-only. """
return False
class SkippedReverificationAdmin(admin.ModelAdmin): class SkippedReverificationAdmin(admin.ModelAdmin):
"""Admin for the SkippedReverification table. """ """Admin for the SkippedReverification table. """
...@@ -58,6 +52,5 @@ class SkippedReverificationAdmin(admin.ModelAdmin): ...@@ -58,6 +52,5 @@ class SkippedReverificationAdmin(admin.ModelAdmin):
admin.site.register(SoftwareSecurePhotoVerification, SoftwareSecurePhotoVerificationAdmin) admin.site.register(SoftwareSecurePhotoVerification, SoftwareSecurePhotoVerificationAdmin)
admin.site.register(InCourseReverificationConfiguration, ConfigurationModelAdmin)
admin.site.register(SkippedReverification, SkippedReverificationAdmin) admin.site.register(SkippedReverification, SkippedReverificationAdmin)
admin.site.register(VerificationStatus, VerificationStatusAdmin) admin.site.register(VerificationStatus, VerificationStatusAdmin)
"""
Image encoding helpers for the verification app.
"""
import logging
log = logging.getLogger(__name__)
class InvalidImageData(Exception):
"""
The provided image data could not be decoded.
"""
pass
def decode_image_data(data):
"""
Decode base64-encoded image data.
Arguments:
data (str): The raw image data, base64-encoded.
Returns:
str
Raises:
InvalidImageData: The image data could not be decoded.
"""
try:
return (data.split(",")[1]).decode("base64")
except (IndexError, UnicodeEncodeError):
log.exception("Could not decode image data")
raise InvalidImageData
...@@ -26,7 +26,7 @@ from django.core.exceptions import ObjectDoesNotExist ...@@ -26,7 +26,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.cache import cache from django.core.cache import cache
from django.dispatch import receiver from django.dispatch import receiver
from django.db import models from django.db import models, transaction, IntegrityError
from django.utils.translation import ugettext as _, ugettext_lazy from django.utils.translation import ugettext as _, ugettext_lazy
from boto.s3.connection import S3Connection from boto.s3.connection import S3Connection
...@@ -590,14 +590,6 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -590,14 +590,6 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds
@classmethod @classmethod
def original_verification(cls, user):
"""
Returns the most current SoftwareSecurePhotoVerification object associated with the user.
"""
query = cls.objects.filter(user=user).order_by('-updated_at')
return query[0]
@classmethod
def get_initial_verification(cls, user): def get_initial_verification(cls, user):
"""Get initial verification for a user """Get initial verification for a user
Arguments: Arguments:
...@@ -632,19 +624,6 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -632,19 +624,6 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key)) s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key))
@status_before_must_be("created") @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): def upload_photo_id_image(self, img_data):
""" """
Upload an the user's photo ID image to S3. `img_data` should be a raw Upload an the user's photo ID image to S3. `img_data` should be a raw
...@@ -676,14 +655,20 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -676,14 +655,20 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
self.save() self.save()
@status_before_must_be("must_retry", "ready", "submitted") @status_before_must_be("must_retry", "ready", "submitted")
def submit(self): def submit(self, copy_id_photo_from=None):
""" """
Submit our verification attempt to Software Secure for validation. This Submit our verification attempt to Software Secure for validation. This
will set our status to "submitted" if the post is successful, and will set our status to "submitted" if the post is successful, and
"must_retry" if the post fails. "must_retry" if the post fails.
Keyword Arguments:
copy_id_photo_from (SoftwareSecurePhotoVerification): If provided, re-send the ID photo
data from this attempt. This is used for reverification, in which new face photos
are sent with previously-submitted ID photos.
""" """
try: try:
response = self.send_request() response = self.send_request(copy_id_photo_from=copy_id_photo_from)
if response.ok: if response.ok:
self.submitted_at = datetime.now(pytz.UTC) self.submitted_at = datetime.now(pytz.UTC)
self.status = "submitted" self.status = "submitted"
...@@ -733,15 +718,28 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -733,15 +718,28 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
log.error('PhotoVerification: Error parsing this error message: %s', self.error_msg) log.error('PhotoVerification: Error parsing this error message: %s', self.error_msg)
return _("There was an error verifying your ID photos.") return _("There was an error verifying your ID photos.")
def image_url(self, name): def image_url(self, name, override_receipt_id=None):
""" """
We dynamically generate this, since we want it the expiration clock to We dynamically generate this, since we want it the expiration clock to
start when the message is created, not when the record is created. start when the message is created, not when the record is created.
Arguments:
name (str): Name of the image (e.g. "photo_id" or "face")
Keyword Arguments:
override_receipt_id (str): If provided, use this receipt ID instead
of the ID for this attempt. This is useful for reverification
where we need to construct a URL to a previously-submitted
photo ID image.
Returns:
string: The expiring URL for the image.
""" """
s3_key = self._generate_s3_key(name) s3_key = self._generate_s3_key(name, override_receipt_id=override_receipt_id)
return s3_key.generate_url(self.IMAGE_LINK_DURATION) return s3_key.generate_url(self.IMAGE_LINK_DURATION)
def _generate_s3_key(self, prefix): def _generate_s3_key(self, prefix, override_receipt_id=None):
""" """
Generates a key for an s3 bucket location Generates a key for an s3 bucket location
...@@ -753,8 +751,14 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -753,8 +751,14 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
) )
bucket = conn.get_bucket(settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["S3_BUCKET"]) bucket = conn.get_bucket(settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["S3_BUCKET"])
# Override the receipt ID if one is provided.
# This allow us to construct S3 keys to images submitted in previous attempts
# (used for reverification, where we send a new face photo with the same photo ID
# from a previous attempt).
receipt_id = self.receipt_id if override_receipt_id is None else override_receipt_id
key = Key(bucket) key = Key(bucket)
key.key = "{}/{}".format(prefix, self.receipt_id) key.key = "{}/{}".format(prefix, receipt_id)
return key return key
...@@ -772,8 +776,19 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -772,8 +776,19 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
return rsa_encrypted_face_aes_key.encode("base64") return rsa_encrypted_face_aes_key.encode("base64")
def create_request(self): def create_request(self, copy_id_photo_from=None):
"""return headers, body_dict""" """
Construct the HTTP request to the photo verification service.
Keyword Arguments:
copy_id_photo_from (SoftwareSecurePhotoVerification): If provided, re-send the ID photo
data from this attempt. This is used for reverification, in which new face photos
are sent with previously-submitted ID photos.
Returns:
tuple of (header, body), where both `header` and `body` are dictionaries.
"""
access_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"] access_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"]
secret_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_SECRET_KEY"] secret_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_SECRET_KEY"]
...@@ -782,11 +797,25 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -782,11 +797,25 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
scheme, settings.SITE_NAME, reverse('verify_student_results_callback') scheme, settings.SITE_NAME, reverse('verify_student_results_callback')
) )
# If we're copying the photo ID image from a previous verification attempt,
# then we need to send the old image data with the correct image key.
photo_id_url = (
self.image_url("photo_id")
if copy_id_photo_from is None
else self.image_url("photo_id", override_receipt_id=copy_id_photo_from.receipt_id)
)
photo_id_key = (
self.photo_id_key
if copy_id_photo_from is None else
copy_id_photo_from.photo_id_key
)
body = { body = {
"EdX-ID": str(self.receipt_id), "EdX-ID": str(self.receipt_id),
"ExpectedName": self.name, "ExpectedName": self.name,
"PhotoID": self.image_url("photo_id"), "PhotoID": photo_id_url,
"PhotoIDKey": self.photo_id_key, "PhotoIDKey": photo_id_key,
"SendResponseTo": callback_url, "SendResponseTo": callback_url,
"UserPhoto": self.image_url("face"), "UserPhoto": self.image_url("face"),
"UserPhotoKey": self._encrypted_user_photo_key_str(), "UserPhotoKey": self._encrypted_user_photo_key_str(),
...@@ -819,11 +848,18 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -819,11 +848,18 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
return header_txt + "\n\n" + body_txt return header_txt + "\n\n" + body_txt
def send_request(self): def send_request(self, copy_id_photo_from=None):
""" """
Assembles a submission to Software Secure and sends it via HTTPS. Assembles a submission to Software Secure and sends it via HTTPS.
Returns a request.Response() object with the reply we get from SS. Keyword Arguments:
copy_id_photo_from (SoftwareSecurePhotoVerification): If provided, re-send the ID photo
data from this attempt. This is used for reverification, in which new face photos
are sent with previously-submitted ID photos.
Returns:
request.Response
""" """
# If AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING is True, we want to # If AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING is True, we want to
# skip posting anything to Software Secure. We actually don't even # skip posting anything to Software Secure. We actually don't even
...@@ -835,14 +871,24 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -835,14 +871,24 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
fake_response.status_code = 200 fake_response.status_code = 200
return fake_response return fake_response
headers, body = self.create_request() headers, body = self.create_request(copy_id_photo_from=copy_id_photo_from)
response = requests.post( response = requests.post(
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"], settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"],
headers=headers, headers=headers,
data=json.dumps(body, indent=2, sort_keys=True, ensure_ascii=False).encode('utf-8'), data=json.dumps(body, indent=2, sort_keys=True, ensure_ascii=False).encode('utf-8'),
verify=False verify=False
) )
log.debug("Sent request to Software Secure for {}".format(self.receipt_id))
log.info("Sent request to Software Secure for receipt ID %s.", self.receipt_id)
if copy_id_photo_from is not None:
log.info(
(
"Software Secure attempt with receipt ID %s used the same photo ID "
"data as the receipt with ID %s"
),
self.receipt_id, copy_id_photo_from.receipt_id
)
log.debug("Headers:\n{}\n\n".format(headers)) log.debug("Headers:\n{}\n\n".format(headers))
log.debug("Body:\n{}\n\n".format(body)) log.debug("Body:\n{}\n\n".format(body))
log.debug("Return code: {}".format(response.status_code)) log.debug("Return code: {}".format(response.status_code))
...@@ -851,27 +897,6 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -851,27 +897,6 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
return response return response
@classmethod @classmethod
def submit_faceimage(cls, user, face_image, photo_id_key):
"""Submit the face image to SoftwareSecurePhotoVerification.
Arguments:
user(User): user object
face_image (bytestream): raw bytestream image data
photo_id_key (str) : SoftwareSecurePhotoVerification attribute
Returns:
SoftwareSecurePhotoVerification Object
"""
b64_face_image = face_image.split(",")[1]
attempt = SoftwareSecurePhotoVerification(user=user)
attempt.upload_face_image(b64_face_image.decode('base64'))
attempt.photo_id_key = photo_id_key
attempt.mark_ready()
attempt.save()
attempt.submit()
return attempt
@classmethod
def verification_status_for_user(cls, user, course_id, user_enrollment_mode): def verification_status_for_user(cls, user, course_id, user_enrollment_mode):
""" """
Returns the verification status for use in grade report. Returns the verification status for use in grade report.
...@@ -1068,21 +1093,30 @@ class VerificationCheckpoint(models.Model): ...@@ -1068,21 +1093,30 @@ class VerificationCheckpoint(models.Model):
return None return None
@classmethod @classmethod
def get_verification_checkpoint(cls, course_id, checkpoint_location): def get_or_create_verification_checkpoint(cls, course_id, checkpoint_location):
"""Get the verification checkpoint for given 'course_id' and """
Get or create the verification checkpoint for given 'course_id' and
checkpoint name. checkpoint name.
Arguments: Arguments:
course_id(CourseKey): CourseKey course_id (CourseKey): CourseKey
checkpoint_location(str): Verification checkpoint location checkpoint_location (str): Verification checkpoint location
Returns: Returns:
VerificationCheckpoint object if exists otherwise None VerificationCheckpoint object if exists otherwise None
""" """
try: try:
checkpoint, __ = cls.objects.get_or_create(course_id=course_id, checkpoint_location=checkpoint_location)
return checkpoint
except IntegrityError:
log.info(
u"An integrity error occurred while getting-or-creating the verification checkpoint "
"for course %s at location %s. This can occur if two processes try to get-or-create "
"the checkpoint at the same time and the database is set to REPEATABLE READ. "
"We will try committing the transaction and retrying."
)
transaction.commit()
return cls.objects.get(course_id=course_id, checkpoint_location=checkpoint_location) return cls.objects.get(course_id=course_id, checkpoint_location=checkpoint_location)
except cls.DoesNotExist:
return None
class VerificationStatus(models.Model): class VerificationStatus(models.Model):
...@@ -1142,6 +1176,29 @@ class VerificationStatus(models.Model): ...@@ -1142,6 +1176,29 @@ class VerificationStatus(models.Model):
cls.objects.create(checkpoint=checkpoint, user=user, status=status) cls.objects.create(checkpoint=checkpoint, user=user, status=status)
@classmethod @classmethod
def get_user_status_at_checkpoint(cls, user, course_key, location):
"""
Get the user's latest status at the checkpoint.
Arguments:
user (User): The user whose status we are retrieving.
course_key (CourseKey): The identifier for the course.
location (UsageKey): The location of the checkpoint in the course.
Returns:
unicode or None
"""
try:
return cls.objects.filter(
user=user,
checkpoint__course_id=course_key,
checkpoint__checkpoint_location=unicode(location),
).latest().status
except cls.DoesNotExist:
return None
@classmethod
def get_user_attempts(cls, user_id, course_key, related_assessment_location): def get_user_attempts(cls, user_id, course_key, related_assessment_location):
""" """
Get re-verification attempts against a user for a given 'checkpoint' Get re-verification attempts against a user for a given 'checkpoint'
...@@ -1180,6 +1237,9 @@ class VerificationStatus(models.Model): ...@@ -1180,6 +1237,9 @@ class VerificationStatus(models.Model):
return "" return ""
# DEPRECATED: this feature has been permanently enabled.
# Once the application code has been updated in production,
# this table can be safely deleted.
class InCourseReverificationConfiguration(ConfigurationModel): class InCourseReverificationConfiguration(ConfigurationModel):
"""Configure in-course re-verification. """Configure in-course re-verification.
......
...@@ -60,10 +60,9 @@ class ReverificationService(object): ...@@ -60,10 +60,9 @@ class ReverificationService(object):
Re-verification link Re-verification link
""" """
course_key = CourseKey.from_string(course_id) course_key = CourseKey.from_string(course_id)
VerificationCheckpoint.objects.get_or_create(
course_id=course_key, # Get-or-create the verification checkpoint
checkpoint_location=related_assessment_location VerificationCheckpoint.get_or_create_verification_checkpoint(course_key, related_assessment_location)
)
re_verification_link = reverse( re_verification_link = reverse(
'verify_student_incourse_reverify', 'verify_student_incourse_reverify',
......
...@@ -40,7 +40,7 @@ class SoftwareSecureFakeView(View): ...@@ -40,7 +40,7 @@ class SoftwareSecureFakeView(View):
} }
try: try:
most_recent = SoftwareSecurePhotoVerification.original_verification(user) most_recent = SoftwareSecurePhotoVerification.objects.filter(user=user).order_by("-updated_at")[0]
context["receipt_id"] = most_recent.receipt_id context["receipt_id"] = most_recent.receipt_id
except: # pylint: disable=bare-except except: # pylint: disable=bare-except
pass pass
......
...@@ -220,18 +220,6 @@ class TestPhotoVerification(ModuleStoreTestCase): ...@@ -220,18 +220,6 @@ class TestPhotoVerification(ModuleStoreTestCase):
return attempt return attempt
def test_fetch_photo_id_image(self):
user = UserFactory.create()
orig_attempt = SoftwareSecurePhotoVerification(user=user)
orig_attempt.save()
old_key = orig_attempt.photo_id_key
new_attempt = SoftwareSecurePhotoVerification(user=user)
new_attempt.save()
new_attempt.fetch_photo_id_image()
assert_equals(new_attempt.photo_id_key, old_key)
def test_submissions(self): def test_submissions(self):
"""Test that we set our status correctly after a submission.""" """Test that we set our status correctly after a submission."""
# Basic case, things go well. # Basic case, things go well.
...@@ -501,7 +489,7 @@ class VerificationCheckpointTest(ModuleStoreTestCase): ...@@ -501,7 +489,7 @@ class VerificationCheckpointTest(ModuleStoreTestCase):
) )
@ddt.data('midterm', 'final') @ddt.data('midterm', 'final')
def test_get_verification_checkpoint(self, checkpoint): def test_get_or_create_verification_checkpoint(self, checkpoint):
""" """
Test that a reverification checkpoint is created properly. Test that a reverification checkpoint is created properly.
""" """
...@@ -514,26 +502,40 @@ class VerificationCheckpointTest(ModuleStoreTestCase): ...@@ -514,26 +502,40 @@ class VerificationCheckpointTest(ModuleStoreTestCase):
checkpoint_location=checkpoint_location checkpoint_location=checkpoint_location
) )
self.assertEqual( self.assertEqual(
VerificationCheckpoint.get_verification_checkpoint(self.course.id, checkpoint_location), VerificationCheckpoint.get_or_create_verification_checkpoint(self.course.id, checkpoint_location),
verification_checkpoint verification_checkpoint
) )
def test_get_verification_checkpoint_for_not_existing_values(self): def test_get_or_create_verification_checkpoint_for_not_existing_values(self):
"""Test that 'get_verification_checkpoint' method returns None if user # Retrieving a checkpoint that doesn't yet exist will create it
tries to access a checkpoint with an invalid location. location = u'i4x://edX/DemoX/edx-reverification-block/invalid_location'
""" checkpoint = VerificationCheckpoint.get_or_create_verification_checkpoint(self.course.id, location)
# create the VerificationCheckpoint checkpoint
VerificationCheckpoint.objects.create(course_id=self.course.id, checkpoint_location=self.checkpoint_midterm)
# get verification for a non existing checkpoint self.assertIsNot(checkpoint, None)
self.assertEqual( self.assertEqual(checkpoint.course_id, self.course.id)
VerificationCheckpoint.get_verification_checkpoint( self.assertEqual(checkpoint.checkpoint_location, location)
self.course.id,
u'i4x://edX/DemoX/edx-reverification-block/invalid_location' def test_get_or_create_integrity_error(self):
), # Create the checkpoint
None VerificationCheckpoint.objects.create(
course_id=self.course.id,
checkpoint_location=self.checkpoint_midterm,
) )
# Simulate that the get-or-create operation raises an IntegrityError
# This can happen when two processes both try to get-or-create at the same time
# when the database is set to REPEATABLE READ.
with patch.object(VerificationCheckpoint.objects, "get_or_create") as mock_get_or_create:
mock_get_or_create.side_effect = IntegrityError
checkpoint = VerificationCheckpoint.get_or_create_verification_checkpoint(
self.course.id,
self.checkpoint_midterm
)
# The checkpoint should be retrieved without error
self.assertEqual(checkpoint.course_id, self.course.id)
self.assertEqual(checkpoint.checkpoint_location, self.checkpoint_midterm)
def test_unique_together_constraint(self): def test_unique_together_constraint(self):
""" """
Test the unique together constraint. Test the unique together constraint.
......
...@@ -7,13 +7,17 @@ import json ...@@ -7,13 +7,17 @@ import json
import urllib import urllib
from datetime import timedelta, datetime from datetime import timedelta, datetime
from uuid import uuid4 from uuid import uuid4
import contextlib
import ddt import ddt
import httpretty import httpretty
import mock import mock
import boto
import moto
import pytz import pytz
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from mock import patch, Mock, ANY from mock import patch, Mock, ANY
import requests
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -44,8 +48,7 @@ from verify_student.views import ( ...@@ -44,8 +48,7 @@ from verify_student.views import (
) )
from verify_student.models import ( from verify_student.models import (
VerificationDeadline, SoftwareSecurePhotoVerification, VerificationDeadline, SoftwareSecurePhotoVerification,
VerificationCheckpoint, InCourseReverificationConfiguration, VerificationCheckpoint, VerificationStatus
VerificationStatus
) )
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -1307,6 +1310,80 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1307,6 +1310,80 @@ class TestSubmitPhotosForVerification(TestCase):
# Check that the user's name was changed in the database # Check that the user's name was changed in the database
self._assert_user_name(self.FULL_NAME) self._assert_user_name(self.FULL_NAME)
def test_submit_photos_sends_confirmation_email(self):
self._submit_photos(
face_image=self.IMAGE_DATA,
photo_id_image=self.IMAGE_DATA
)
self._assert_confirmation_email(True)
def test_submit_photos_error_does_not_send_email(self):
# Error because invalid parameters, so no confirmation email
# should be sent.
self._submit_photos(expected_status_code=400)
self._assert_confirmation_email(False)
# Disable auto-auth since we will be intercepting POST requests
# to the verification service ourselves in this test.
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': False})
@override_settings(VERIFY_STUDENT={
"SOFTWARE_SECURE": {
"API_URL": "https://verify.example.com/submit/",
"API_ACCESS_KEY": "dcf291b5572942f99adaab4c2090c006",
"API_SECRET_KEY": "c392efdcc0354c5f922dc39844ec0dc7",
"FACE_IMAGE_AES_KEY": "f82400259e3b4f88821cd89838758292",
"RSA_PUBLIC_KEY": (
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDkgtz3fQdiXshy/RfOHkoHlhx/"
"SSPZ+nNyE9JZXtwhlzsXjnu+e9GOuJzgh4kUqo73ePIG5FxVU+mnacvufq2cu1SOx"
"lRYGyBK7qDf9Ym67I5gmmcNhbzdKcluAuDCPmQ4ecKpICQQldrDQ9HWDxwjbbcqpVB"
"PYWkE1KrtypGThmcehLmabf6SPq1CTAGlXsHgUtbWCwV6mqR8yScV0nRLln0djLDm9d"
"L8tIVFFVpAfBaYYh2Cm5EExQZjxyfjWd8P5H+8/l0pmK2jP7Hc0wuXJemIZbsdm+DSD"
"FhCGY3AILGkMwr068dGRxfBtBy/U9U5W+nStvkDdMrSgQezS5+V test@example.com"
),
"AWS_ACCESS_KEY": "c987c7efe35c403caa821f7328febfa1",
"AWS_SECRET_KEY": "fc595fc657c04437bb23495d8fe64881",
"S3_BUCKET": "test.example.com",
}
})
@moto.mock_s3
def test_submit_photos_for_reverification(self):
# Create the S3 bucket for photo upload
conn = boto.connect_s3()
conn.create_bucket("test.example.com")
# Submit an initial verification attempt
with self._intercept_post() as mock_post:
self._submit_photos(
face_image=self.IMAGE_DATA + "4567",
photo_id_image=self.IMAGE_DATA + "8910",
)
initial_data = self._get_post_data(mock_post)
# Submit a face photo for re-verification
with self._intercept_post() as mock_post:
self._submit_photos(face_image=self.IMAGE_DATA + "1112")
reverification_data = self._get_post_data(mock_post)
# Verify that the initial attempt sent the same ID photo as the reverification attempt
self.assertEqual(initial_data["PhotoIDKey"], reverification_data["PhotoIDKey"])
initial_photo_response = requests.get(initial_data["PhotoID"])
self.assertEqual(initial_photo_response.status_code, 200)
reverification_photo_response = requests.get(reverification_data["PhotoID"])
self.assertEqual(reverification_photo_response.status_code, 200)
self.assertEqual(initial_photo_response.content, reverification_photo_response.content)
# Verify that the second attempt sent the updated face photo
initial_photo_response = requests.get(initial_data["UserPhoto"])
self.assertEqual(initial_photo_response.status_code, 200)
reverification_photo_response = requests.get(reverification_data["UserPhoto"])
self.assertEqual(reverification_photo_response.status_code, 200)
self.assertNotEqual(initial_photo_response.content, reverification_photo_response.content)
@ddt.data('face_image', 'photo_id_image') @ddt.data('face_image', 'photo_id_image')
def test_invalid_image_data(self, invalid_param): def test_invalid_image_data(self, invalid_param):
params = { params = {
...@@ -1326,19 +1403,32 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1326,19 +1403,32 @@ class TestSubmitPhotosForVerification(TestCase):
) )
self.assertEqual(response.content, "Name must be at least 2 characters long.") self.assertEqual(response.content, "Name must be at least 2 characters long.")
@ddt.data('face_image', 'photo_id_image') def test_missing_required_param(self):
def test_missing_required_params(self, missing_param): # Missing face image parameter
params = { params = {
'face_image': self.IMAGE_DATA,
'photo_id_image': self.IMAGE_DATA 'photo_id_image': self.IMAGE_DATA
} }
del params[missing_param]
response = self._submit_photos(expected_status_code=400, **params) response = self._submit_photos(expected_status_code=400, **params)
self.assertEqual(response.content, "Missing required parameter face_image")
def test_no_photo_id_and_no_initial_verification(self):
# Submit face image data, but not photo ID data.
# Since the user doesn't have an initial verification attempt, this should fail
response = self._submit_photos(expected_status_code=400, face_image=self.IMAGE_DATA)
self.assertEqual( self.assertEqual(
response.content, response.content,
"Missing required parameters: {missing}".format(missing=missing_param) "Photo ID image is required if the user does not have an initial verification attempt."
) )
# Create the initial verification attempt
self._submit_photos(
face_image=self.IMAGE_DATA,
photo_id_image=self.IMAGE_DATA,
)
# Now the request should succeed
self._submit_photos(face_image=self.IMAGE_DATA)
def _submit_photos(self, face_image=None, photo_id_image=None, full_name=None, expected_status_code=200): def _submit_photos(self, face_image=None, photo_id_image=None, full_name=None, expected_status_code=200):
"""Submit photos for verification. """Submit photos for verification.
...@@ -1367,7 +1457,13 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1367,7 +1457,13 @@ class TestSubmitPhotosForVerification(TestCase):
response = self.client.post(url, params) response = self.client.post(url, params)
self.assertEqual(response.status_code, expected_status_code) self.assertEqual(response.status_code, expected_status_code)
if expected_status_code == 200: return response
def _assert_confirmation_email(self, expect_email):
"""
Check that a confirmation email was or was not sent.
"""
if expect_email:
# Verify that photo submission confirmation email was sent # Verify that photo submission confirmation email was sent
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual("Verification photos received", mail.outbox[0].subject) self.assertEqual("Verification photos received", mail.outbox[0].subject)
...@@ -1375,8 +1471,6 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1375,8 +1471,6 @@ class TestSubmitPhotosForVerification(TestCase):
# Verify that photo submission confirmation email was not sent # Verify that photo submission confirmation email was not sent
self.assertEqual(len(mail.outbox), 0) self.assertEqual(len(mail.outbox), 0)
return response
def _assert_user_name(self, full_name): def _assert_user_name(self, full_name):
"""Check the user's name. """Check the user's name.
...@@ -1390,6 +1484,36 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1390,6 +1484,36 @@ class TestSubmitPhotosForVerification(TestCase):
account_settings = get_account_settings(self.user) account_settings = get_account_settings(self.user)
self.assertEqual(account_settings['name'], full_name) self.assertEqual(account_settings['name'], full_name)
@contextlib.contextmanager
def _intercept_post(self):
"""
Mock `requests.post()` to always return a 200 status code.
Yields:
mock.Mock
"""
with patch("verify_student.models.requests.post") as mock_post:
mock_post.return_value = mock.Mock(
status_code=200,
text="Fake response",
)
yield mock_post
def _get_post_data(self, mock_post):
"""
Retrieve data sent with a mock POST request.
Arguments:
mock_post (mock.Mock): The mock for `requests.post()`.
Returns:
dict
"""
__, kwargs = mock_post.call_args
return json.loads(kwargs["data"])
class TestPhotoVerificationResultsCallback(ModuleStoreTestCase): class TestPhotoVerificationResultsCallback(ModuleStoreTestCase):
""" """
...@@ -1613,10 +1737,6 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase): ...@@ -1613,10 +1737,6 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase):
""" """
self.create_reverification_xblock() self.create_reverification_xblock()
incourse_reverify_enabled = InCourseReverificationConfiguration.current()
incourse_reverify_enabled.enabled = True
incourse_reverify_enabled.save()
data = { data = {
"EdX-ID": self.receipt_id, "EdX-ID": self.receipt_id,
"Result": "PASS", "Result": "PASS",
...@@ -1821,8 +1941,6 @@ class TestInCourseReverifyView(ModuleStoreTestCase): ...@@ -1821,8 +1941,6 @@ class TestInCourseReverifyView(ModuleStoreTestCase):
# Enroll the user in the default mode (honor) to emulate # Enroll the user in the default mode (honor) to emulate
CourseEnrollment.enroll(self.user, self.course_key, mode="verified") CourseEnrollment.enroll(self.user, self.course_key, mode="verified")
self.config = InCourseReverificationConfiguration(enabled=True)
self.config.save()
# mocking and patching for bi events # mocking and patching for bi events
analytics_patcher = patch('verify_student.views.analytics') analytics_patcher = patch('verify_student.views.analytics')
...@@ -1830,22 +1948,10 @@ class TestInCourseReverifyView(ModuleStoreTestCase): ...@@ -1830,22 +1948,10 @@ class TestInCourseReverifyView(ModuleStoreTestCase):
self.addCleanup(analytics_patcher.stop) self.addCleanup(analytics_patcher.stop)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_feature_flag_get(self):
self.config.enabled = False
self.config.save()
response = self.client.get(self._get_url(self.course_key, self.reverification_location))
self.assertEquals(response.status_code, 404)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_invalid_course_get(self):
response = self.client.get(self._get_url("invalid/course/key", self.reverification_location))
self.assertEquals(response.status_code, 404)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_invalid_checkpoint_get(self): def test_incourse_reverify_invalid_checkpoint_get(self):
# Retrieve a checkpoint that doesn't yet exist
response = self.client.get(self._get_url(self.course_key, "invalid_checkpoint")) response = self.client.get(self._get_url(self.course_key, "invalid_checkpoint"))
self.assertEquals(response.status_code, 404) self.assertEqual(response.status_code, 404)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_initial_redirect_get(self): def test_incourse_reverify_initial_redirect_get(self):
...@@ -1853,6 +1959,7 @@ class TestInCourseReverifyView(ModuleStoreTestCase): ...@@ -1853,6 +1959,7 @@ class TestInCourseReverifyView(ModuleStoreTestCase):
response = self.client.get(self._get_url(self.course_key, self.reverification_location)) response = self.client.get(self._get_url(self.course_key, self.reverification_location))
url = reverse('verify_student_verify_now', kwargs={"course_id": unicode(self.course_key)}) url = reverse('verify_student_verify_now', kwargs={"course_id": unicode(self.course_key)})
url += u"?{params}".format(params=urllib.urlencode({"checkpoint": self.reverification_location}))
self.assertRedirects(response, url) self.assertRedirects(response, url)
@override_settings(SEGMENT_IO_LMS_KEY="foobar") @override_settings(SEGMENT_IO_LMS_KEY="foobar")
...@@ -1890,23 +1997,24 @@ class TestInCourseReverifyView(ModuleStoreTestCase): ...@@ -1890,23 +1997,24 @@ class TestInCourseReverifyView(ModuleStoreTestCase):
"""Verify that POST requests including an invalid checkpoint location """Verify that POST requests including an invalid checkpoint location
results in a 400 response. results in a 400 response.
""" """
response = self.client.post(self._get_url(self.course_key, self.reverification_location)) response = self._submit_photos(self.course_key, self.reverification_location, self.IMAGE_DATA)
self.assertEquals(response.status_code, 400) self.assertEquals(response.status_code, 400)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_initial_redirect_post(self): def test_incourse_reverify_id_required_if_no_initial_verification(self):
self._create_checkpoint() self._create_checkpoint()
response = self.client.post(self._get_url(self.course_key, self.reverification_location))
url = reverse('verify_student_verify_now', kwargs={"course_id": unicode(self.course_key)}) # Since the user has no initial verification and we're not sending the ID photo,
self.assertRedirects(response, url) # we should expect a 400 bad request
response = self._submit_photos(self.course_key, self.reverification_location, self.IMAGE_DATA)
self.assertEqual(response.status_code, 400)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_index_error_post(self): def test_incourse_reverify_index_error_post(self):
self._create_checkpoint() self._create_checkpoint()
self._create_initial_verification() self._create_initial_verification()
response = self.client.post(self._get_url(self.course_key, self.reverification_location), {"face_image": ""}) response = self._submit_photos(self.course_key, self.reverification_location, "")
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@override_settings(SEGMENT_IO_LMS_KEY="foobar") @override_settings(SEGMENT_IO_LMS_KEY="foobar")
...@@ -1915,13 +2023,16 @@ class TestInCourseReverifyView(ModuleStoreTestCase): ...@@ -1915,13 +2023,16 @@ class TestInCourseReverifyView(ModuleStoreTestCase):
self._create_checkpoint() self._create_checkpoint()
self._create_initial_verification() self._create_initial_verification()
response = self.client.post( response = self._submit_photos(self.course_key, self.reverification_location, self.IMAGE_DATA)
self._get_url(self.course_key, self.reverification_location),
{"face_image": self.IMAGE_DATA}
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# test that Google Analytics event firs after successfully submitting # Check that the checkpoint status has been updated
status = VerificationStatus.get_user_status_at_checkpoint(
self.user, self.course_key, self.reverification_location
)
self.assertEqual(status, "submitted")
# Test that Google Analytics event fires after successfully submitting
# photo verification # photo verification
self.mock_tracker.track.assert_called_once_with( # pylint: disable=no-member self.mock_tracker.track.assert_called_once_with( # pylint: disable=no-member
self.user.id, self.user.id,
...@@ -1938,14 +2049,6 @@ class TestInCourseReverifyView(ModuleStoreTestCase): ...@@ -1938,14 +2049,6 @@ class TestInCourseReverifyView(ModuleStoreTestCase):
) )
self.mock_tracker.reset_mock() self.mock_tracker.reset_mock()
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_incourse_reverify_feature_flag_post(self):
self.config.enabled = False
self.config.save()
response = self.client.post(self._get_url(self.course_key, self.reverification_location))
self.assertEquals(response.status_code, 404)
def _create_checkpoint(self): def _create_checkpoint(self):
""" """
Helper method for creating a reverification checkpoint. Helper method for creating a reverification checkpoint.
...@@ -1981,6 +2084,16 @@ class TestInCourseReverifyView(ModuleStoreTestCase): ...@@ -1981,6 +2084,16 @@ class TestInCourseReverifyView(ModuleStoreTestCase):
} }
) )
def _submit_photos(self, course_key, checkpoint_location, face_image_data):
""" Submit photos for verification. """
url = reverse("verify_student_submit_photos")
data = {
"course_key": unicode(course_key),
"checkpoint": checkpoint_location,
"face_image": face_image_data,
}
return self.client.post(url, data)
class TestEmailMessageWithCustomICRVBlock(ModuleStoreTestCase): class TestEmailMessageWithCustomICRVBlock(ModuleStoreTestCase):
""" """
......
...@@ -91,7 +91,7 @@ urlpatterns = patterns( ...@@ -91,7 +91,7 @@ urlpatterns = patterns(
url( url(
r'^submit-photos/$', r'^submit-photos/$',
views.submit_photos_for_verification, views.SubmitPhotosView.as_view(),
name="verify_student_submit_photos" name="verify_student_submit_photos"
), ),
......
...@@ -6,6 +6,7 @@ import datetime ...@@ -6,6 +6,7 @@ import datetime
import decimal import decimal
import json import json
import logging import logging
import urllib
from pytz import UTC from pytz import UTC
from ipware.ip import get_ip from ipware.ip import get_ip
...@@ -25,8 +26,8 @@ from django.views.generic.base import View, RedirectView ...@@ -25,8 +26,8 @@ from django.views.generic.base import View, RedirectView
import analytics import analytics
from eventtracking import tracker from eventtracking import tracker
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from commerce import ecommerce_api_client from commerce import ecommerce_api_client
from commerce.utils import audit_log from commerce.utils import audit_log
...@@ -37,7 +38,7 @@ from edxmako.shortcuts import render_to_response, render_to_string ...@@ -37,7 +38,7 @@ from edxmako.shortcuts import render_to_response, render_to_string
from embargo import api as embargo_api from embargo import api as embargo_api
from microsite_configuration import microsite from microsite_configuration import microsite
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings from openedx.core.djangoapps.user_api.accounts.api import update_account_settings
from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError
from openedx.core.djangoapps.credit.api import set_credit_requirement_status from openedx.core.djangoapps.credit.api import set_credit_requirement_status
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -51,12 +52,11 @@ from verify_student.models import ( ...@@ -51,12 +52,11 @@ from verify_student.models import (
SoftwareSecurePhotoVerification, SoftwareSecurePhotoVerification,
VerificationCheckpoint, VerificationCheckpoint,
VerificationStatus, VerificationStatus,
InCourseReverificationConfiguration
) )
from verify_student.image import decode_image_data, InvalidImageData
from util.json_request import JsonResponse from util.json_request import JsonResponse
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from staticfiles.storage import staticfiles_storage from staticfiles.storage import staticfiles_storage
...@@ -64,7 +64,8 @@ log = logging.getLogger(__name__) ...@@ -64,7 +64,8 @@ log = logging.getLogger(__name__)
class PayAndVerifyView(View): class PayAndVerifyView(View):
"""View for the "verify and pay" flow. """
View for the "verify and pay" flow.
This view is somewhat complicated, because the user This view is somewhat complicated, because the user
can enter it from a number of different places: can enter it from a number of different places:
...@@ -234,9 +235,9 @@ class PayAndVerifyView(View): ...@@ -234,9 +235,9 @@ class PayAndVerifyView(View):
course_key = CourseKey.from_string(course_id) course_key = CourseKey.from_string(course_id)
course = modulestore().get_course(course_key) course = modulestore().get_course(course_key)
# Verify that the course exists and has a verified mode # Verify that the course exists
if course is None: if course is None:
log.warn(u"No course specified for verification flow request.") log.warn(u"Could not find course with ID %s.", course_id)
raise Http404 raise Http404
# Check whether the user has access to this course # Check whether the user has access to this course
...@@ -399,6 +400,7 @@ class PayAndVerifyView(View): ...@@ -399,6 +400,7 @@ class PayAndVerifyView(View):
'contribution_amount': contribution_amount, 'contribution_amount': contribution_amount,
'course': course, 'course': course,
'course_key': unicode(course_key), 'course_key': unicode(course_key),
'checkpoint_location': request.GET.get('checkpoint'),
'course_mode': relevant_course_mode, 'course_mode': relevant_course_mode,
'courseware_url': courseware_url, 'courseware_url': courseware_url,
'current_step': current_step, 'current_step': current_step,
...@@ -800,35 +802,169 @@ def create_order(request): ...@@ -800,35 +802,169 @@ def create_order(request):
return HttpResponse(json.dumps(payment_data), content_type="application/json") return HttpResponse(json.dumps(payment_data), content_type="application/json")
@require_POST class SubmitPhotosView(View):
@login_required """
def submit_photos_for_verification(request): End-point for submitting photos for verification.
"""Submit a photo verification attempt. """
Arguments: @method_decorator(login_required)
request (HttpRequest): The request to submit photos. def post(self, request):
"""
Submit photos for verification.
Returns: This end-point is used for the following cases:
HttpResponse: 200 on success, 400 if there are errors.
""" * Initial verification through the pay-and-verify flow.
# Check the required parameters * Initial verification initiated from a checkpoint within a course.
missing_params = set(['face_image', 'photo_id_image']) - set(request.POST.keys()) * Re-verification initiated from a checkpoint within a course.
if len(missing_params) > 0:
msg = _("Missing required parameters: {missing}").format(missing=", ".join(missing_params)) POST Parameters:
return HttpResponseBadRequest(msg)
face_image (str): base64-encoded image data of the user's face.
# If the user already has valid or pending request, the UI will hide photo_id_image (str): base64-encoded image data of the user's photo ID.
# the verification steps. For this reason, we reject any requests full_name (str): The user's full name, if the user is requesting a name change as well.
# for users that already have a valid or pending verification. course_key (str): Identifier for the course, if initiated from a checkpoint.
if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user): checkpoint (str): Location of the checkpoint in the course.
return HttpResponseBadRequest(_("You already have a valid or pending verification."))
"""
# If the user wants to change his/her full name, # If the user already has an initial verification attempt, we can re-use the photo ID
# then try to do that before creating the attempt. # the user submitted with the initial attempt. This is useful for the in-course reverification
if request.POST.get('full_name'): # case in which users submit only the face photo and have it matched against their ID photos
# submitted with the initial verification.
initial_verification = SoftwareSecurePhotoVerification.get_initial_verification(request.user)
# Validate the POST parameters
params, response = self._validate_parameters(request, bool(initial_verification))
if response is not None:
return response
# If necessary, update the user's full name
if "full_name" in params:
response = self._update_full_name(request.user, params["full_name"])
if response is not None:
return response
# Retrieve the image data
# Validation ensures that we'll have a face image, but we may not have
# a photo ID image if this is a reverification.
face_image, photo_id_image, response = self._decode_image_data(
params["face_image"], params.get("photo_id_image")
)
if response is not None:
return response
# Submit the attempt
attempt = self._submit_attempt(request.user, face_image, photo_id_image, initial_verification)
# If this attempt was submitted at a checkpoint, then associate
# the attempt with the checkpoint.
submitted_at_checkpoint = "checkpoint" in params and "course_key" in params
if submitted_at_checkpoint:
checkpoint = self._associate_attempt_with_checkpoint(
request.user, attempt,
params["course_key"],
params["checkpoint"]
)
# If the submission came from an in-course checkpoint
if initial_verification is not None and submitted_at_checkpoint:
self._fire_event(request.user, "edx.bi.reverify.submitted", {
"category": "verification",
"label": unicode(params["course_key"]),
"checkpoint": checkpoint.checkpoint_name,
})
# Send a URL that the client can redirect to in order
# to return to the checkpoint in the courseware.
redirect_url = get_redirect_url(params["course_key"], params["checkpoint"])
return JsonResponse({"url": redirect_url})
# Otherwise, the submission came from an initial verification flow.
else:
self._fire_event(request.user, "edx.bi.verify.submitted", {"category": "verification"})
self._send_confirmation_email(request.user)
redirect_url = None
return JsonResponse({})
def _validate_parameters(self, request, has_initial_verification):
"""
Check that the POST parameters are valid.
Arguments:
request (HttpRequest): The request object.
has_initial_verification (bool): Whether the user has an initial verification attempt.
Returns:
HttpResponse or None
"""
# Pull out the parameters we care about.
params = {
param_name: request.POST[param_name]
for param_name in [
"face_image",
"photo_id_image",
"course_key",
"checkpoint",
"full_name"
]
if param_name in request.POST
}
# If the user already has an initial verification attempt, then we don't
# require the user to submit a photo ID image, since we can re-use the photo ID
# image from the initial attempt.
# If we don't have an initial verification OR a photo ID image, something has gone
# terribly wrong in the JavaScript. Log this as an error so we can track it down.
if "photo_id_image" not in params and not has_initial_verification:
log.error(
(
"User %s does not have an initial verification attempt "
"and no photo ID image data was provided. "
"This most likely means that the JavaScript client is not "
"correctly constructing the request to submit photos."
), request.user.id
)
return None, HttpResponseBadRequest(
_("Photo ID image is required if the user does not have an initial verification attempt.")
)
# The face image is always required.
if "face_image" not in params:
msg = _("Missing required parameter face_image")
return None, HttpResponseBadRequest(msg)
# If provided, parse the course key and checkpoint location
if "course_key" in params:
try:
params["course_key"] = CourseKey.from_string(params["course_key"])
except InvalidKeyError:
return None, HttpResponseBadRequest(_("Invalid course key"))
if "checkpoint" in params:
try:
params["checkpoint"] = UsageKey.from_string(params["checkpoint"]).replace(
course_key=params["course_key"]
)
except InvalidKeyError:
return None, HttpResponseBadRequest(_("Invalid checkpoint location"))
return params, None
def _update_full_name(self, user, full_name):
"""
Update the user's full name.
Arguments:
user (User): The user to update.
full_name (unicode): The user's updated full name.
Returns:
HttpResponse or None
"""
try: try:
update_account_settings(request.user, {"name": request.POST.get('full_name')}) update_account_settings(user, {"name": full_name})
except UserNotFound: except UserNotFound:
return HttpResponseBadRequest(_("No profile found for user")) return HttpResponseBadRequest(_("No profile found for user"))
except AccountValidationError: except AccountValidationError:
...@@ -837,38 +973,131 @@ def submit_photos_for_verification(request): ...@@ -837,38 +973,131 @@ def submit_photos_for_verification(request):
).format(min_length=NAME_MIN_LENGTH) ).format(min_length=NAME_MIN_LENGTH)
return HttpResponseBadRequest(msg) return HttpResponseBadRequest(msg)
# Create the attempt def _decode_image_data(self, face_data, photo_id_data=None):
attempt = SoftwareSecurePhotoVerification(user=request.user) """
try: Decode image data sent with the request.
b64_face_image = request.POST['face_image'].split(",")[1]
b64_photo_id_image = request.POST['photo_id_image'].split(",")[1]
except IndexError:
msg = _("Image data is not valid.")
return HttpResponseBadRequest(msg)
attempt.upload_face_image(b64_face_image.decode('base64')) Arguments:
attempt.upload_photo_id_image(b64_photo_id_image.decode('base64')) face_data (str): base64-encoded face image data.
attempt.mark_ready()
attempt.submit()
log.info(u"Submitted initial verification attempt for user %s", request.user.id) Keyword Arguments:
photo_id_data (str): base64-encoded photo ID image data.
account_settings = get_account_settings(request.user) Returns:
tuple of (str, str, HttpResponse)
# Send a confirmation email to the user """
context = { try:
'full_name': account_settings['name'], # Decode face image data (used for both an initial and re-verification)
'platform_name': settings.PLATFORM_NAME face_image = decode_image_data(face_data)
}
subject = _("Verification photos received") # Decode the photo ID image data if it's provided
message = render_to_string('emails/photo_submission_confirmation.txt', context) photo_id_image = (
from_address = microsite.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL) decode_image_data(photo_id_data)
to_address = account_settings['email'] if photo_id_data is not None else None
)
send_mail(subject, message, from_address, [to_address], fail_silently=False) return face_image, photo_id_image, None
return HttpResponse(200) except InvalidImageData:
msg = _("Image data is not valid.")
return None, None, HttpResponseBadRequest(msg)
def _submit_attempt(self, user, face_image, photo_id_image=None, initial_verification=None):
"""
Submit a verification attempt.
Arguments:
user (User): The user making the attempt.
face_image (str): Decoded face image data.
Keyword Arguments:
photo_id_image (str or None): Decoded photo ID image data.
initial_verification (SoftwareSecurePhotoVerification): The initial verification attempt.
"""
attempt = SoftwareSecurePhotoVerification(user=user)
# We will always have face image data, so upload the face image
attempt.upload_face_image(face_image)
# If an ID photo wasn't submitted, re-use the ID photo from the initial attempt.
# Earlier validation rules ensure that at least one of these is available.
if photo_id_image is not None:
attempt.upload_photo_id_image(photo_id_image)
elif initial_verification is None:
# Earlier validation should ensure that we never get here.
log.error(
"Neither a photo ID image or initial verification attempt provided. "
"Parameter validation in the view should prevent this from happening!"
)
# Submit the attempt
attempt.mark_ready()
attempt.submit(copy_id_photo_from=initial_verification)
return attempt
def _associate_attempt_with_checkpoint(self, user, attempt, course_key, usage_id):
"""
Associate the verification attempt with a checkpoint within a course.
Arguments:
user (User): The user making the attempt.
attempt (SoftwareSecurePhotoVerification): The verification attempt.
course_key (CourseKey): The identifier for the course.
usage_key (UsageKey): The location of the checkpoint within the course.
Returns:
VerificationCheckpoint
"""
checkpoint = VerificationCheckpoint.get_or_create_verification_checkpoint(course_key, usage_id)
checkpoint.add_verification_attempt(attempt)
VerificationStatus.add_verification_status(checkpoint, user, "submitted")
return checkpoint
def _send_confirmation_email(self, user):
"""
Send an email confirming that the user submitted photos
for initial verification.
"""
context = {
'full_name': user.profile.name,
'platform_name': microsite.get_value("PLATFORM_NAME", settings.PLATFORM_NAME)
}
subject = _("Verification photos received")
message = render_to_string('emails/photo_submission_confirmation.txt', context)
from_address = microsite.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL)
to_address = user.email
try:
send_mail(subject, message, from_address, [to_address], fail_silently=False)
except: # pylint: disable=bare-except
# We catch all exceptions and log them.
# It would be much, much worse to roll back the transaction due to an uncaught
# exception than to skip sending the notification email.
log.exception("Could not send notification email for initial verification for user %s", user.id)
def _fire_event(self, user, event_name, parameters):
"""
Fire an analytics event.
Arguments:
user (User): The user who submitted photos.
event_name (str): Name of the analytics event.
parameters (dict): Event parameters.
Returns: None
"""
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
tracking_context = tracker.get_tracker().resolve_context()
context = {
'Google Analytics': {
'clientId': tracking_context.get('client_id')
}
}
analytics.track(user.id, event_name, parameters, context=context)
def _compose_message_reverification_email( def _compose_message_reverification_email(
...@@ -1066,21 +1295,22 @@ def results_callback(request): ...@@ -1066,21 +1295,22 @@ def results_callback(request):
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Result {} not understood. Known results: PASS, FAIL, SYSTEM FAIL".format(result) "Result {} not understood. Known results: PASS, FAIL, SYSTEM FAIL".format(result)
) )
incourse_reverify_enabled = InCourseReverificationConfiguration.current().enabled
if incourse_reverify_enabled:
checkpoints = VerificationCheckpoint.objects.filter(photo_verification=attempt).all()
VerificationStatus.add_status_from_checkpoints(checkpoints=checkpoints, user=attempt.user, status=status)
# If this is re-verification then send the update email
if checkpoints:
user_id = attempt.user.id
course_key = checkpoints[0].course_id
related_assessment_location = checkpoints[0].checkpoint_location
subject, message = _compose_message_reverification_email(
course_key, user_id, related_assessment_location, status, request
)
_send_email(user_id, subject, message) checkpoints = VerificationCheckpoint.objects.filter(photo_verification=attempt).all()
VerificationStatus.add_status_from_checkpoints(checkpoints=checkpoints, user=attempt.user, status=status)
# If this is re-verification then send the update email
if checkpoints:
user_id = attempt.user.id
course_key = checkpoints[0].course_id
related_assessment_location = checkpoints[0].checkpoint_location
subject, message = _compose_message_reverification_email(
course_key, user_id, related_assessment_location, status, request
)
_send_email(user_id, subject, message)
return HttpResponse("OK!") return HttpResponse("OK!")
...@@ -1145,17 +1375,6 @@ class InCourseReverifyView(View): ...@@ -1145,17 +1375,6 @@ class InCourseReverifyView(View):
Returns: Returns:
HttpResponse HttpResponse
""" """
# Check that in-course re-verification is enabled or not
incourse_reverify_enabled = InCourseReverificationConfiguration.current().enabled
if not incourse_reverify_enabled:
log.error(
u"In-course reverification is not enabled. "
u"You can enable it in Django admin by setting "
u"InCourseReverificationConfiguration to enabled."
)
raise Http404
user = request.user user = request.user
course_key = CourseKey.from_string(course_id) course_key = CourseKey.from_string(course_id)
course = modulestore().get_course(course_key) course = modulestore().get_course(course_key)
...@@ -1163,8 +1382,9 @@ class InCourseReverifyView(View): ...@@ -1163,8 +1382,9 @@ class InCourseReverifyView(View):
log.error(u"Could not find course '%s' for in-course reverification.", course_key) log.error(u"Could not find course '%s' for in-course reverification.", course_key)
raise Http404 raise Http404
checkpoint = VerificationCheckpoint.get_verification_checkpoint(course_key, usage_id) try:
if checkpoint is None: checkpoint = VerificationCheckpoint.objects.get(course_id=course_key, checkpoint_location=usage_id)
except VerificationCheckpoint.DoesNotExist:
log.error( log.error(
u"No verification checkpoint exists for the " u"No verification checkpoint exists for the "
u"course '%s' and checkpoint location '%s'.", u"course '%s' and checkpoint location '%s'.",
...@@ -1174,7 +1394,7 @@ class InCourseReverifyView(View): ...@@ -1174,7 +1394,7 @@ class InCourseReverifyView(View):
initial_verification = SoftwareSecurePhotoVerification.get_initial_verification(user) initial_verification = SoftwareSecurePhotoVerification.get_initial_verification(user)
if not initial_verification: if not initial_verification:
return self._redirect_no_initial_verification(user, course_key) return self._redirect_to_initial_verification(user, course_key, usage_id)
# emit the reverification event # emit the reverification event
self._track_reverification_events('edx.bi.reverify.started', user.id, course_id, checkpoint.checkpoint_name) self._track_reverification_events('edx.bi.reverify.started', user.id, course_id, checkpoint.checkpoint_name)
...@@ -1189,86 +1409,6 @@ class InCourseReverifyView(View): ...@@ -1189,86 +1409,6 @@ class InCourseReverifyView(View):
} }
return render_to_response("verify_student/incourse_reverify.html", context) return render_to_response("verify_student/incourse_reverify.html", context)
@method_decorator(login_required)
def post(self, request, course_id, usage_id):
"""Submits the re-verification attempt to SoftwareSecure.
Args:
request(HttpRequest): HttpRequest object
course_id(str): Course Id
usage_id(str): Location of Reverification XBlock in courseware
Returns:
HttpResponse with status_code 400 if photo is missing or any error
or redirect to the verification flow if initial verification
doesn't exist otherwise HttpResponse with status code 200
"""
# Check the in-course re-verification is enabled or not
incourse_reverify_enabled = InCourseReverificationConfiguration.current().enabled
if not incourse_reverify_enabled:
raise Http404
user = request.user
try:
course_key = CourseKey.from_string(course_id)
usage_key = UsageKey.from_string(usage_id).replace(course_key=course_key)
except InvalidKeyError:
raise Http404(u"Invalid course_key or usage_key")
course = modulestore().get_course(course_key)
if course is None:
log.error(u"Invalid course id '%s'", course_id)
return HttpResponseBadRequest(_("Invalid course location."))
checkpoint = VerificationCheckpoint.get_verification_checkpoint(course_key, usage_id)
if checkpoint is None:
log.error(
u"Checkpoint is not defined. Could not submit verification attempt"
u" for user '%s', course '%s' and checkpoint location '%s'.",
request.user.id, course_key, usage_id
)
return HttpResponseBadRequest(_("Invalid checkpoint location."))
init_verification = SoftwareSecurePhotoVerification.get_initial_verification(user)
if not init_verification:
return self._redirect_no_initial_verification(user, course_key)
try:
attempt = SoftwareSecurePhotoVerification.submit_faceimage(
request.user, request.POST['face_image'], init_verification.photo_id_key
)
checkpoint.add_verification_attempt(attempt)
VerificationStatus.add_verification_status(checkpoint, user, "submitted")
# emit the reverification event
self._track_reverification_events(
'edx.bi.reverify.submitted',
user.id, course_id, checkpoint.checkpoint_name
)
redirect_url = get_redirect_url(course_key, usage_key)
response = JsonResponse({'url': redirect_url})
except (ItemNotFoundError, NoPathToItem):
log.warning(u"Could not find redirect URL for location %s in course %s", course_key, usage_key)
redirect_url = reverse("courseware", args=(unicode(course_key),))
response = JsonResponse({'url': redirect_url})
except Http404 as expt:
log.exception("Invalid location during photo verification.")
response = HttpResponseBadRequest(expt.message)
except IndexError:
log.exception("Invalid image data during photo verification.")
response = HttpResponseBadRequest(_("Invalid image data during photo verification."))
except Exception: # pylint: disable=broad-except
log.exception("Could not submit verification attempt for user %s.", request.user.id)
msg = _("Could not submit photos")
response = HttpResponseBadRequest(msg)
return response
def _track_reverification_events(self, event_name, user_id, course_id, checkpoint): # pylint: disable=invalid-name def _track_reverification_events(self, event_name, user_id, course_id, checkpoint): # pylint: disable=invalid-name
"""Track re-verification events for a user against a reverification """Track re-verification events for a user against a reverification
checkpoint of a course. checkpoint of a course.
...@@ -1304,31 +1444,35 @@ class InCourseReverifyView(View): ...@@ -1304,31 +1444,35 @@ class InCourseReverifyView(View):
} }
) )
def _redirect_no_initial_verification(self, user, course_key): def _redirect_to_initial_verification(self, user, course_key, checkpoint):
"""Redirect because the user does not have an initial verification. """
Redirect because the user does not have an initial verification.
NOTE: currently, we assume that courses are configured such that We will redirect the user to the initial verification flow,
the first re-verification always occurs AFTER the initial verification passing the identifier for this checkpoint. When the user
deadline. Later, we may want to allow users to upgrade to a verified submits a verification attempt, it will count for *both*
track, then submit an initial verification that also counts the initial and checkpoint verification.
as a verification for the checkpoint in the course.
Arguments: Arguments:
user (User): The user who made the request. user (User): The user who made the request.
course_key (CourseKey): The identifier for the course for which course_key (CourseKey): The identifier for the course for which
the user is attempting to re-verify. the user is attempting to re-verify.
checkpoint (string): Location of the checkpoint in the courseware.
Returns: Returns:
HttpResponse HttpResponse
""" """
log.warning( log.info(
u"User %s does not have an initial verification, so " u"User %s does not have an initial verification, so "
u"he/she will be redirected to the \"verify later\" flow " u"he/she will be redirected to the \"verify later\" flow "
u"for the course %s.", u"for the course %s.",
user.id, course_key user.id, course_key
) )
return redirect(reverse('verify_student_verify_now', kwargs={'course_id': unicode(course_key)})) base_url = reverse('verify_student_verify_now', kwargs={'course_id': unicode(course_key)})
params = urllib.urlencode({"checkpoint": checkpoint})
full_url = u"{base}?{params}".format(base=base_url, params=params)
return redirect(full_url)
class VerifyLaterView(RedirectView): class VerifyLaterView(RedirectView):
......
...@@ -1331,7 +1331,7 @@ incourse_reverify_js = [ ...@@ -1331,7 +1331,7 @@ incourse_reverify_js = [
'js/verify_student/views/error_view.js', 'js/verify_student/views/error_view.js',
'js/verify_student/views/image_input_view.js', 'js/verify_student/views/image_input_view.js',
'js/verify_student/views/webcam_photo_view.js', 'js/verify_student/views/webcam_photo_view.js',
'js/verify_student/models/reverification_model.js', 'js/verify_student/models/verification_model.js',
'js/verify_student/views/incourse_reverify_view.js', 'js/verify_student/views/incourse_reverify_view.js',
'js/verify_student/incourse_reverify.js', 'js/verify_student/incourse_reverify.js',
] ]
......
/**
* Model for a reverification attempt.
*
* The re-verification model is responsible for
* storing face photo image data and submitting
* it back to the server.
*/
var edx = edx || {};
(function( $, _, Backbone ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.ReverificationModel = Backbone.Model.extend({
defaults: {
courseKey: '',
faceImage: '',
usageId: ''
},
sync: function( method ) {
var model = this;
var headers = { 'X-CSRFToken': $.cookie( 'csrftoken' ) },
data = {
face_image: model.get( 'faceImage' )
},
url = _.str.sprintf(
'/verify_student/reverify/%(courseKey)s/%(usageId)s/', {
courseKey: model.get('courseKey'),
usageId: model.get('usageId')
}
);
$.ajax({
url: url,
type: 'POST',
data: data,
headers: headers,
success: function(response) {
model.trigger( 'sync', response.url);
},
error: function( error ) {
model.trigger( 'error', error );
}
});
}
});
})( jQuery, _, Backbone );
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
*/ */
var edx = edx || {}; var edx = edx || {};
(function( $, _, Backbone ) { (function( $, Backbone ) {
'use strict'; 'use strict';
edx.verify_student = edx.verify_student || {}; edx.verify_student = edx.verify_student || {};
...@@ -16,22 +16,40 @@ ...@@ -16,22 +16,40 @@
edx.verify_student.VerificationModel = Backbone.Model.extend({ edx.verify_student.VerificationModel = Backbone.Model.extend({
defaults: { defaults: {
// If provided, change the user's full name when submitting photos.
fullName: null, fullName: null,
// Image data for the user's face photo.
faceImage: "", faceImage: "",
identificationImage: ""
// Image data for the user's ID photo.
// In the case of an in-course reverification, we won't
// need to send this because we'll send the ID photo that
// the user submitted with the initial verification attempt.
identificationImage: null,
// If the verification attempt is associated with a checkpoint
// in a course, we send the the course and checkpoint location.
courseKey: null,
checkpoint: null,
}, },
sync: function( method, model ) { sync: function( method, model ) {
var headers = { 'X-CSRFToken': $.cookie( 'csrftoken' ) }, var headers = { 'X-CSRFToken': $.cookie( 'csrftoken' ) },
data = { data = {};
face_image: model.get( 'faceImage' ),
photo_id_image: model.get( 'identificationImage' ) data.face_image = model.get("faceImage");
};
// The ID photo is optional, since in-course reverification
// re-uses the image from the initial verification attempt.
if (model.has("identificationImage")) {
data.photo_id_image = model.get("identificationImage");
}
// Full name is an optional parameter; if not provided, // Full name is an optional parameter; if not provided,
// it won't be changed. // it won't be changed.
if ( !_.isEmpty( model.get( 'fullName' ) ) ) { if (model.has("fullName")) {
data.full_name = model.get( 'fullName' ); data.full_name = model.get("fullName");
// Track the user's decision to change the name on their account // Track the user's decision to change the name on their account
window.analytics.track( 'edx.bi.user.full_name.changed', { window.analytics.track( 'edx.bi.user.full_name.changed', {
...@@ -39,6 +57,14 @@ ...@@ -39,6 +57,14 @@
}); });
} }
// If the user entered the verification flow from a checkpoint
// in a course, we need to send the course and checkpoint
// location to associate the attempt with the checkpoint.
if (model.has("courseKey") && model.has("checkpoint")) {
data.course_key = model.get("courseKey");
data.checkpoint = model.get("checkpoint");
}
// Submit the request to the server, // Submit the request to the server,
// triggering events on success and error. // triggering events on success and error.
$.ajax({ $.ajax({
...@@ -46,8 +72,8 @@ ...@@ -46,8 +72,8 @@
type: 'POST', type: 'POST',
data: data, data: data,
headers: headers, headers: headers,
success: function() { success: function( response ) {
model.trigger( 'sync' ); model.trigger( 'sync', response.url );
}, },
error: function( error ) { error: function( error ) {
model.trigger( 'error', error ); model.trigger( 'error', error );
...@@ -56,4 +82,4 @@ ...@@ -56,4 +82,4 @@
} }
}); });
})( jQuery, _, Backbone ); })( jQuery, Backbone );
...@@ -34,6 +34,8 @@ var edx = edx || {}; ...@@ -34,6 +34,8 @@ var edx = edx || {};
errorModel: errorView.model, errorModel: errorView.model,
displaySteps: el.data('display-steps'), displaySteps: el.data('display-steps'),
currentStep: el.data('current-step'), currentStep: el.data('current-step'),
courseKey: el.data('course-key'),
checkpointLocation: el.data('checkpoint-location'),
stepInfo: { stepInfo: {
'intro-step': { 'intro-step': {
courseName: el.data('course-name'), courseName: el.data('course-name'),
......
...@@ -30,9 +30,9 @@ ...@@ -30,9 +30,9 @@
this.usageId = obj.usageId || null; this.usageId = obj.usageId || null;
this.model = new edx.verify_student.ReverificationModel({ this.model = new edx.verify_student.VerificationModel({
courseKey: this.courseKey, courseKey: this.courseKey,
usageId: this.usageId checkpoint: this.usageId
}); });
this.listenTo( this.model, 'sync', _.bind( this.handleSubmitPhotoSuccess, this )); this.listenTo( this.model, 'sync', _.bind( this.handleSubmitPhotoSuccess, this ));
...@@ -74,8 +74,7 @@ ...@@ -74,8 +74,7 @@
}, },
handleSubmitPhotoSuccess: function(redirect_url) { handleSubmitPhotoSuccess: function(redirect_url) {
// Eventually this will be a redirect back into the courseware, // Redirect back to the courseware at the checkpoint location
// but for now we can return to the student dashboard.
window.location.href = redirect_url; window.location.href = redirect_url;
}, },
......
...@@ -27,6 +27,9 @@ var edx = edx || {}; ...@@ -27,6 +27,9 @@ var edx = edx || {};
initialize: function( obj ) { initialize: function( obj ) {
this.errorModel = obj.errorModel || null; this.errorModel = obj.errorModel || null;
this.displaySteps = obj.displaySteps || []; this.displaySteps = obj.displaySteps || [];
this.courseKey = obj.courseKey || null;
this.checkpointLocation = obj.checkpointLocation || null;
this.initializeStepViews( obj.stepInfo || {} ); this.initializeStepViews( obj.stepInfo || {} );
this.currentStepIndex = _.indexOf( this.currentStepIndex = _.indexOf(
_.pluck( this.displaySteps, 'name' ), _.pluck( this.displaySteps, 'name' ),
...@@ -61,7 +64,14 @@ var edx = edx || {}; ...@@ -61,7 +64,14 @@ var edx = edx || {};
// among the different steps. This allows // among the different steps. This allows
// one step to save photos and another step // one step to save photos and another step
// to submit them. // to submit them.
verificationModel = new edx.verify_student.VerificationModel(); //
// We also pass in the course key and checkpoint location.
// If we've been provided with a checkpoint in the courseware,
// this will associate the verification attempt with the checkpoint.
verificationModel = new edx.verify_student.VerificationModel({
courseKey: this.courseKey,
checkpoint: this.checkpointLocation
});
for ( i = 0; i < this.displaySteps.length; i++ ) { for ( i = 0; i < this.displaySteps.length; i++ ) {
stepName = this.displaySteps[i].name; stepName = this.displaySteps[i].name;
......
...@@ -75,6 +75,13 @@ from verify_student.views import PayAndVerifyView ...@@ -75,6 +75,13 @@ from verify_student.views import PayAndVerifyView
data-already-verified='${already_verified}' data-already-verified='${already_verified}'
data-verification-good-until='${verification_good_until}' data-verification-good-until='${verification_good_until}'
data-capture-sound='${capture_sound}' data-capture-sound='${capture_sound}'
# If we reached the verification flow from an in-course checkpoint,
# then pass the checkpoint location in so that we can associate
# the attempt with the checkpoint on submission.
% if checkpoint_location is not None:
data-checkpoint-location='${checkpoint_location}'
% endif
></div> ></div>
% if is_active: % if is_active:
......
...@@ -136,6 +136,7 @@ freezegun==0.1.11 ...@@ -136,6 +136,7 @@ freezegun==0.1.11
lettuce==0.2.20 lettuce==0.2.20
mock-django==0.6.6 mock-django==0.6.6
mock==1.0.1 mock==1.0.1
moto==0.3.1
nose-exclude nose-exclude
nose-ignore-docstring nose-ignore-docstring
nosexcover==1.0.7 nosexcover==1.0.7
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment