Basic first commit of Photo ID Verification model and test code
This commit is contained in:
0
lms/djangoapps/verify_student/__init__.py
Normal file
0
lms/djangoapps/verify_student/__init__.py
Normal file
0
lms/djangoapps/verify_student/api.py
Normal file
0
lms/djangoapps/verify_student/api.py
Normal file
322
lms/djangoapps/verify_student/models.py
Normal file
322
lms/djangoapps/verify_student/models.py
Normal file
@@ -0,0 +1,322 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Models for Student Identity Verification
|
||||
|
||||
Currently the only model is `PhotoVerificationAttempt`, but this is where we
|
||||
would put any models relating to establishing the real-life identity of a
|
||||
student over a period of time.
|
||||
"""
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytz
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from model_utils.models import StatusModel
|
||||
from model_utils import Choices
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VerificationException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class IdVerifiedCourses(models.Model):
|
||||
"""
|
||||
A table holding all the courses that are eligible for ID Verification.
|
||||
"""
|
||||
course_id = models.CharField(blank=False, max_length=100)
|
||||
|
||||
|
||||
def status_before_must_be(*valid_start_statuses):
|
||||
"""
|
||||
Decorator with arguments to make sure that an object with a `status`
|
||||
attribute is in one of a list of acceptable status states before a method
|
||||
is called. You could use it in a class definition like:
|
||||
|
||||
@status_before_must_be("submitted", "approved", "denied")
|
||||
def refund_user(self, user_id):
|
||||
# Do logic here...
|
||||
|
||||
If the object has a status that is not listed when the `refund_user` method
|
||||
is invoked, it will throw a `VerificationException`. This is just to avoid
|
||||
distracting boilerplate when looking at a Model that needs to go through a
|
||||
workflow process.
|
||||
"""
|
||||
def decorator_func(fn):
|
||||
@functools.wraps(fn)
|
||||
def with_status_check(obj, *args, **kwargs):
|
||||
if obj.status not in valid_start_statuses:
|
||||
exception_msg = (
|
||||
u"Error calling {} {}: status is '{}', must be one of: {}"
|
||||
).format(fn, obj, obj.status, valid_start_statuses)
|
||||
raise VerificationException(exception_msg)
|
||||
return fn(obj, *args, **kwargs)
|
||||
|
||||
return with_status_check
|
||||
|
||||
return decorator_func
|
||||
|
||||
|
||||
class PhotoVerificationAttempt(StatusModel):
|
||||
"""
|
||||
Each PhotoVerificationAttempt represents a Student's attempt to establish
|
||||
their identity by uploading a photo of themselves and a picture ID. An
|
||||
attempt actually has a number of fields that need to be filled out at
|
||||
different steps of the approval process. While it's useful as a Django Model
|
||||
for the querying facilities, **you should only create and edit a
|
||||
`PhotoVerificationAttempt` object through the methods provided**. Do not
|
||||
just construct one and start setting fields unless you really know what
|
||||
you're doing.
|
||||
|
||||
We track this attempt through various states:
|
||||
|
||||
`created`
|
||||
Initial creation and state we're in after uploading the images.
|
||||
`ready`
|
||||
The user has uploaded their images and checked that they can read the
|
||||
images. There's a separate state here because it may be the case that we
|
||||
don't actually submit this attempt for review until payment is made.
|
||||
`submitted`
|
||||
Submitted for review. The review may be done by a staff member or an
|
||||
external service. The user cannot make changes once in this state.
|
||||
`approved`
|
||||
An admin or an external service has confirmed that the user's photo and
|
||||
photo ID match up, and that the photo ID's name matches the user's.
|
||||
`denied`
|
||||
The request has been denied. See `error_msg` for details on why. An
|
||||
admin might later override this and change to `approved`, but the
|
||||
student cannot re-open this attempt -- they have to create another
|
||||
attempt and submit it instead.
|
||||
|
||||
Because this Model inherits from StatusModel, we can also do things like::
|
||||
|
||||
attempt.status == PhotoVerificationAttempt.STATUS.created
|
||||
attempt.status == "created"
|
||||
pending_requests = PhotoVerificationAttempt.submitted.all()
|
||||
"""
|
||||
######################## Fields Set During Creation ########################
|
||||
# See class docstring for description of status states
|
||||
STATUS = Choices('created', 'ready', 'submitted', 'approved', 'denied')
|
||||
user = models.ForeignKey(User, db_index=True)
|
||||
|
||||
# They can change their name later on, so we want to copy the value here so
|
||||
# we always preserve what it was at the time they requested. We only copy
|
||||
# this value during the mark_ready() step. Prior to that, you should be
|
||||
# displaying the user's name from their user.profile.name.
|
||||
name = models.CharField(blank=True, max_length=255)
|
||||
|
||||
# Where we place the uploaded image files (e.g. S3 URLs)
|
||||
face_image_url = models.URLField(blank=True, max_length=255)
|
||||
photo_id_image_url = models.URLField(blank=True, max_length=255)
|
||||
|
||||
# Randomly generated UUID so that external services can post back the
|
||||
# results of checking a user's photo submission without use exposing actual
|
||||
# user IDs or something too easily guessable.
|
||||
receipt_id = models.CharField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, db_index=True)
|
||||
|
||||
|
||||
######################## Fields Set When Submitting ########################
|
||||
submitted_at = models.DateTimeField(null=True, db_index=True)
|
||||
|
||||
|
||||
#################### Fields Set During Approval/Denial #####################
|
||||
# If the review was done by an internal staff member, mark who it was.
|
||||
reviewing_user = models.ForeignKey(
|
||||
User,
|
||||
db_index=True,
|
||||
default=None,
|
||||
null=True,
|
||||
related_name="photo_verifications_reviewed"
|
||||
)
|
||||
|
||||
# Mark the name of the service used to evaluate this attempt (e.g
|
||||
# Software Secure).
|
||||
reviewing_service = models.CharField(blank=True, max_length=255)
|
||||
|
||||
# If status is "denied", this should contain text explaining why.
|
||||
error_msg = models.TextField(blank=True)
|
||||
|
||||
# Non-required field. External services can add any arbitrary codes as time
|
||||
# goes on. We don't try to define an exhuastive list -- this is just
|
||||
# capturing it so that we can later query for the common problems.
|
||||
error_code = models.CharField(blank=True, max_length=50)
|
||||
|
||||
|
||||
##### Methods listed in the order you'd typically call them
|
||||
@classmethod
|
||||
def user_is_verified(cls, user_id):
|
||||
"""Returns 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."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@classmethod
|
||||
def active_for_user(cls, user_id):
|
||||
"""Return all PhotoVerificationAttempts that are still active (i.e. not
|
||||
approved or denied).
|
||||
|
||||
Should there only be one active at any given time for a user? Enforced
|
||||
at the DB level?
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@status_before_must_be("created")
|
||||
def upload_face_image(self, img):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@status_before_must_be("created")
|
||||
def upload_photo_id_image(self, img):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@status_before_must_be("created")
|
||||
def mark_ready(self):
|
||||
"""
|
||||
Mark that the user data in this attempt is correct. In order to
|
||||
succeed, the user must have uploaded the necessary images
|
||||
(`face_image_url`, `photo_id_image_url`). This method will also copy
|
||||
their name from their user profile. Prior to marking it ready, we read
|
||||
this value directly from their profile, since they're free to change it.
|
||||
This often happens because people put in less formal versions of their
|
||||
name on signup, but realize they want something different to go on a
|
||||
formal document.
|
||||
|
||||
Valid attempt statuses when calling this method:
|
||||
`created`
|
||||
|
||||
Status after method completes: `ready`
|
||||
|
||||
Other fields that will be set by this method:
|
||||
`name`
|
||||
|
||||
State Transitions:
|
||||
|
||||
`created` → `ready`
|
||||
This is what happens when the user confirms to us that the pictures
|
||||
they uploaded are good. Note that we don't actually do a submission
|
||||
anywhere yet.
|
||||
"""
|
||||
if not self.face_image_url:
|
||||
raise VerificationException("No face image was uploaded.")
|
||||
if not self.photo_id_image_url:
|
||||
raise VerificationException("No photo ID image was uploaded.")
|
||||
|
||||
# At any point prior to this, they can change their names via their
|
||||
# student dashboard. But at this point, we lock the value into the
|
||||
# attempt.
|
||||
self.name = self.user.profile.name
|
||||
self.status = "ready"
|
||||
self.save()
|
||||
|
||||
|
||||
@status_before_must_be("ready", "submit")
|
||||
def submit(self, reviewing_service=None):
|
||||
if self.status == "submitted":
|
||||
return
|
||||
|
||||
if reviewing_service:
|
||||
reviewing_service.submit(self)
|
||||
self.submitted_at = datetime.now(pytz.UTC)
|
||||
self.status = "submitted"
|
||||
self.save()
|
||||
|
||||
|
||||
@status_before_must_be("submitted", "approved", "denied")
|
||||
def approve(self, user_id=None, service=""):
|
||||
"""
|
||||
Approve this attempt. `user_id`
|
||||
|
||||
Valid attempt statuses when calling this method:
|
||||
`submitted`, `approved`, `denied`
|
||||
|
||||
Status after method completes: `approved`
|
||||
|
||||
Other fields that will be set by this method:
|
||||
`reviewed_by_user_id`, `reviewed_by_service`, `error_msg`
|
||||
|
||||
State Transitions:
|
||||
|
||||
`submitted` → `approved`
|
||||
This is the usual flow, whether initiated by a staff user or an
|
||||
external validation service.
|
||||
`approved` → `approved`
|
||||
No-op. First one to approve it wins.
|
||||
`denied` → `approved`
|
||||
This might happen if a staff member wants to override a decision
|
||||
made by an external service or another staff member (say, in
|
||||
response to a support request). In this case, the previous values
|
||||
of `reviewed_by_user_id` and `reviewed_by_service` will be changed
|
||||
to whoever is doing the approving, and `error_msg` will be reset.
|
||||
The only record that this record was ever denied would be in our
|
||||
logs. This should be a relatively rare occurence.
|
||||
"""
|
||||
# If someone approves an outdated version of this, the first one wins
|
||||
if self.status == "approved":
|
||||
return
|
||||
|
||||
self.error_msg = "" # reset, in case this attempt was denied before
|
||||
self.error_code = "" # reset, in case this attempt was denied before
|
||||
self.reviewing_user = user_id
|
||||
self.reviewing_service = service
|
||||
self.status = "approved"
|
||||
self.save()
|
||||
|
||||
|
||||
@status_before_must_be("submitted", "approved", "denied")
|
||||
def deny(self,
|
||||
error_msg,
|
||||
error_code="",
|
||||
reviewing_user=None,
|
||||
reviewing_service=""):
|
||||
"""
|
||||
Deny this attempt.
|
||||
|
||||
Valid attempt statuses when calling this method:
|
||||
`submitted`, `approved`, `denied`
|
||||
|
||||
Status after method completes: `denied`
|
||||
|
||||
Other fields that will be set by this method:
|
||||
`reviewed_by_user_id`, `reviewed_by_service`, `error_msg`, `error_code`
|
||||
|
||||
State Transitions:
|
||||
|
||||
`submitted` → `denied`
|
||||
This is the usual flow, whether initiated by a staff user or an
|
||||
external validation service.
|
||||
`approved` → `denied`
|
||||
This might happen if a staff member wants to override a decision
|
||||
made by an external service or another staff member, or just correct
|
||||
a mistake made during the approval process. In this case, the
|
||||
previous values of `reviewed_by_user_id` and `reviewed_by_service`
|
||||
will be changed to whoever is doing the denying. The only record
|
||||
that this record was ever approved would be in our logs. This should
|
||||
be a relatively rare occurence.
|
||||
`denied` → `denied`
|
||||
Update the error message and reviewing_user/reviewing_service. Just
|
||||
lets you amend the error message in case there were additional
|
||||
details to be made.
|
||||
"""
|
||||
self.error_msg = error_msg
|
||||
self.error_code = error_code
|
||||
self.reviewing_user = reviewing_user
|
||||
self.reviewing_service = reviewing_service
|
||||
self.status = "denied"
|
||||
self.save()
|
||||
|
||||
|
||||
0
lms/djangoapps/verify_student/tests/__init__.py
Normal file
0
lms/djangoapps/verify_student/tests/__init__.py
Normal file
59
lms/djangoapps/verify_student/tests/test_models.py
Normal file
59
lms/djangoapps/verify_student/tests/test_models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from nose.tools import assert_in, assert_is_none, assert_equals, \
|
||||
assert_raises, assert_not_equals
|
||||
from django.test import TestCase
|
||||
from student.tests.factories import UserFactory
|
||||
from verify_student.models import PhotoVerificationAttempt, VerificationException
|
||||
|
||||
|
||||
class TestPhotoVerificationAttempt(object):
|
||||
|
||||
def test_state_transitions(self):
|
||||
"""Make sure we can't make unexpected status transitions.
|
||||
|
||||
The status transitions we expect are::
|
||||
|
||||
created → ready → submitted → approved
|
||||
↑ ↓
|
||||
→ denied
|
||||
"""
|
||||
user = UserFactory.create()
|
||||
attempt = PhotoVerificationAttempt(user=user)
|
||||
assert_equals(attempt.status, PhotoVerificationAttempt.STATUS.created)
|
||||
assert_equals(attempt.status, "created")
|
||||
|
||||
# This should fail because we don't have the necessary fields filled out
|
||||
assert_raises(VerificationException, attempt.mark_ready)
|
||||
|
||||
# These should all fail because we're in the wrong starting state.
|
||||
assert_raises(VerificationException, attempt.submit)
|
||||
assert_raises(VerificationException, attempt.approve)
|
||||
assert_raises(VerificationException, attempt.deny)
|
||||
|
||||
# Now let's fill in some values so that we can pass the mark_ready() call
|
||||
attempt.face_image_url = "http://fake.edx.org/face.jpg"
|
||||
attempt.photo_id_image_url = "http://fake.edx.org/photo_id.jpg"
|
||||
attempt.mark_ready()
|
||||
assert_equals(attempt.name, user.profile.name) # Move this to another test
|
||||
assert_equals(attempt.status, "ready")
|
||||
|
||||
# Once again, state transitions should fail here. We can't approve or
|
||||
# deny anything until it's been placed into the submitted state -- i.e.
|
||||
# the user has clicked on whatever agreements, or given payment, or done
|
||||
# whatever the application requires before it agrees to process their
|
||||
# attempt.
|
||||
assert_raises(VerificationException, attempt.approve)
|
||||
assert_raises(VerificationException, attempt.deny)
|
||||
|
||||
# Now we submit
|
||||
attempt.submit()
|
||||
assert_equals(attempt.status, "submitted")
|
||||
|
||||
# So we should be able to both approve and deny
|
||||
attempt.approve()
|
||||
assert_equals(attempt.status, "approved")
|
||||
|
||||
attempt.deny("Could not read name on Photo ID")
|
||||
assert_equals(attempt.status, "denied")
|
||||
|
||||
|
||||
37
lms/djangoapps/verify_student/tests/test_views.py
Normal file
37
lms/djangoapps/verify_student/tests/test_views.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
|
||||
|
||||
verify_student/start?course_id=MITx/6.002x/2013_Spring # create
|
||||
/upload_face?course_id=MITx/6.002x/2013_Spring
|
||||
/upload_photo_id
|
||||
/confirm # mark_ready()
|
||||
|
||||
---> To Payment
|
||||
|
||||
"""
|
||||
import urllib
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
class StartView(TestCase):
|
||||
|
||||
def start_url(course_id=""):
|
||||
return "/verify_student/start?course_id={0}".format(urllib.quote(course_id))
|
||||
|
||||
def test_start_new_verification(self):
|
||||
"""
|
||||
Test the case where the user has no pending `PhotoVerficiationAttempts`,
|
||||
but is just starting their first.
|
||||
"""
|
||||
user = UserFactory.create(username="rusty", password="test")
|
||||
self.client.login(username="rusty", password="test")
|
||||
|
||||
def must_be_logged_in(self):
|
||||
self.assertHttpForbidden(self.client.get(self.start_url()))
|
||||
|
||||
|
||||
0
lms/djangoapps/verify_student/urls.py
Normal file
0
lms/djangoapps/verify_student/urls.py
Normal file
13
lms/djangoapps/verify_student/views.py
Normal file
13
lms/djangoapps/verify_student/views.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
|
||||
|
||||
"""
|
||||
|
||||
@login_required
|
||||
def start(request):
|
||||
"""
|
||||
If they've already started a PhotoVerificationAttempt, we move to wherever
|
||||
they are in that process. If they've completed one, then we skip straight
|
||||
to payment.
|
||||
"""
|
||||
|
||||
@@ -775,6 +775,9 @@ INSTALLED_APPS = (
|
||||
|
||||
# Different Course Modes
|
||||
'course_modes'
|
||||
|
||||
# Student Identity Verification
|
||||
'verify_student',
|
||||
)
|
||||
|
||||
######################### MARKETING SITE ###############################
|
||||
|
||||
@@ -12,6 +12,7 @@ django-followit==0.0.3
|
||||
django-keyedcache==1.4-6
|
||||
django-kombu==0.9.4
|
||||
django-mako==0.1.5pre
|
||||
django-model-utils==1.4.0
|
||||
django-masquerade==0.1.6
|
||||
django-mptt==0.5.5
|
||||
django-openid-auth==0.4
|
||||
|
||||
Reference in New Issue
Block a user