From 0db7884354e2dbc0dc4aee97111554ec7791ab40 Mon Sep 17 00:00:00 2001 From: David Ormsbee <dave@edx.org> Date: Fri, 9 Aug 2013 12:14:29 -0400 Subject: [PATCH] Basic first commit of Photo ID Verification model and test code --- lms/djangoapps/verify_student/__init__.py | 0 lms/djangoapps/verify_student/api.py | 0 .../verify_student/migrations/__init__.py | 0 lms/djangoapps/verify_student/models.py | 322 ++++++++++++++++++ .../verify_student/tests/__init__.py | 0 .../verify_student/tests/test_models.py | 59 ++++ .../verify_student/tests/test_views.py | 37 ++ lms/djangoapps/verify_student/urls.py | 0 lms/djangoapps/verify_student/views.py | 13 + lms/envs/common.py | 3 + requirements/edx/base.txt | 1 + 11 files changed, 435 insertions(+) create mode 100644 lms/djangoapps/verify_student/__init__.py create mode 100644 lms/djangoapps/verify_student/api.py create mode 100644 lms/djangoapps/verify_student/migrations/__init__.py create mode 100644 lms/djangoapps/verify_student/models.py create mode 100644 lms/djangoapps/verify_student/tests/__init__.py create mode 100644 lms/djangoapps/verify_student/tests/test_models.py create mode 100644 lms/djangoapps/verify_student/tests/test_views.py create mode 100644 lms/djangoapps/verify_student/urls.py create mode 100644 lms/djangoapps/verify_student/views.py diff --git a/lms/djangoapps/verify_student/__init__.py b/lms/djangoapps/verify_student/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lms/djangoapps/verify_student/api.py b/lms/djangoapps/verify_student/api.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lms/djangoapps/verify_student/migrations/__init__.py b/lms/djangoapps/verify_student/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py new file mode 100644 index 00000000000..852bc4a50f6 --- /dev/null +++ b/lms/djangoapps/verify_student/models.py @@ -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() + + diff --git a/lms/djangoapps/verify_student/tests/__init__.py b/lms/djangoapps/verify_student/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py new file mode 100644 index 00000000000..2c80447d6cc --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -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") + + diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py new file mode 100644 index 00000000000..47b08f7b357 --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -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())) + + \ No newline at end of file diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py new file mode 100644 index 00000000000..964f8fa0f30 --- /dev/null +++ b/lms/djangoapps/verify_student/views.py @@ -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. + """ + \ No newline at end of file diff --git a/lms/envs/common.py b/lms/envs/common.py index 250552a40cd..d4ff040d5f2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -775,6 +775,9 @@ INSTALLED_APPS = ( # Different Course Modes 'course_modes' + + # Student Identity Verification + 'verify_student', ) ######################### MARKETING SITE ############################### diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9179315797c..070f0a060d4 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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 -- GitLab