Merge pull request #18070 from edx/bexline/sso_id_verification2

ENT-945 Using SSOVerifications in ID Verification flow
This commit is contained in:
Brittney Exline
2018-04-30 14:14:03 -06:00
committed by GitHub
23 changed files with 406 additions and 316 deletions

View File

@@ -116,14 +116,10 @@ def check_verify_status_by_course(user, course_enrollments):
verifications = IDVerificationService.verifications_for_user(user)
# Check whether the user has an active or pending verification attempt
# To avoid another database hit, we re-use the queryset we have already retrieved.
has_active_or_pending = IDVerificationService.user_has_valid_or_pending(
user, queryset=verifications
)
has_active_or_pending = IDVerificationService.user_has_valid_or_pending(user)
# Retrieve expiration_datetime of most recent approved verification
# To avoid another database hit, we re-use the queryset we have already retrieved.
expiration_datetime = IDVerificationService.get_expiration_datetime(user, verifications)
expiration_datetime = IDVerificationService.get_expiration_datetime(user, ['approved'])
verification_expiring_soon = is_verification_expiring_soon(expiration_datetime)
# Retrieve verification deadlines for the enrolled courses
@@ -154,9 +150,12 @@ def check_verify_status_by_course(user, course_enrollments):
# By default, don't show any status related to verification
status = None
should_display = True
# Check whether the user was approved or is awaiting approval
if relevant_verification is not None:
should_display = relevant_verification.should_display_status_to_user()
if relevant_verification.status == "approved":
if verification_expiring_soon:
status = VERIFY_STATUS_NEED_TO_REVERIFY
@@ -214,7 +213,8 @@ def check_verify_status_by_course(user, course_enrollments):
status_by_course[enrollment.course_id] = {
'status': status,
'days_until_deadline': days_until_deadline
'days_until_deadline': days_until_deadline,
'should_display': should_display,
}
if recent_verification_datetime:

View File

@@ -716,8 +716,8 @@ def student_dashboard(request):
# Verification Attempts
# Used to generate the "you must reverify for course x" banner
verification_status, verification_error_codes = IDVerificationService.user_status(user)
verification_errors = get_verification_error_reasons_for_display(verification_error_codes)
verification_status = IDVerificationService.user_status(user)
verification_errors = get_verification_error_reasons_for_display(verification_status['error'])
# Gets data for midcourse reverifications, if any are necessary or have failed
statuses = ["approved", "denied", "pending", "must_reverify"]
@@ -770,7 +770,9 @@ def student_dashboard(request):
redirect_message = ''
valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired']
display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses
display_sidebar_on_dashboard = (len(order_history_list) or
(verification_status['status'] in valid_verification_statuses and
verification_status['should_display']))
# Filter out any course enrollment course cards that are associated with fulfilled entitlements
for entitlement in [e for e in course_entitlements if e.enrollment_course_run is not None]:
@@ -802,7 +804,8 @@ def student_dashboard(request):
'credit_statuses': _credit_statuses(user, course_enrollments),
'show_email_settings_for': show_email_settings_for,
'reverifications': reverifications,
'verification_status': verification_status,
'verification_display': verification_status['should_display'],
'verification_status': verification_status['status'],
'verification_status_by_course': verify_status_by_course,
'verification_errors': verification_errors,
'block_courses': block_courses,

View File

@@ -598,9 +598,9 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
# inject verification status
if verification_service:
verification_status, __ = verification_service.get_status(user_id)
verification_status = verification_service.get_status(user_id)
context.update({
'verification_status': verification_status,
'verification_status': verification_status['status'],
'reverify_url': verification_service.reverify_url(),
})

View File

@@ -82,7 +82,7 @@ def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylin
user_enrollments = CourseEnrollment.enrollments_for_user(user=user)
grade_factory = CourseGradeFactory()
expected_verification_status, _ = IDVerificationService.user_status(user)
expected_verification_status = IDVerificationService.user_status(user)
for enrollment in user_enrollments:
if grade_factory.read(user=user, course=enrollment.course_overview).passed:
if fire_ungenerated_certificate_task(user, enrollment.course_id, expected_verification_status):
@@ -93,7 +93,7 @@ def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylin
log.info(message.format(
user=user.id,
course=enrollment.course_id,
status=expected_verification_status
status=expected_verification_status['status']
))

View File

@@ -31,7 +31,7 @@ def generate_certificate(self, **kwargs):
course_key = CourseKey.from_string(kwargs.pop('course_key'))
expected_verification_status = kwargs.pop('expected_verification_status', None)
if expected_verification_status:
actual_verification_status, _ = IDVerificationService.user_status(student)
actual_verification_status = IDVerificationService.user_status(student)
if expected_verification_status != actual_verification_status:
raise self.retry(kwargs=original_kwargs)
generate_user_certificates(student=student, course_key=course_key, **kwargs)

View File

@@ -256,12 +256,17 @@ class LearnerTrackChangeCertsTest(ModuleStoreTestCase):
status='submitted'
)
attempt.approve()
expected_verification_status = {
'status': 'approved',
'error': '',
'should_display': True,
}
mock_generate_certificate_apply_async.assert_called_with(
countdown=CERTIFICATE_DELAY_SECONDS,
kwargs={
'student': unicode(self.user_one.id),
'course_key': unicode(self.course_one.id),
'expected_verification_status': SoftwareSecurePhotoVerification.STATUS.approved
'expected_verification_status': unicode(expected_verification_status),
}
)
@@ -277,12 +282,17 @@ class LearnerTrackChangeCertsTest(ModuleStoreTestCase):
status='submitted'
)
attempt.approve()
expected_verification_status = {
'status': 'approved',
'error': '',
'should_display': True,
}
mock_generate_certificate_apply_async.assert_called_with(
countdown=CERTIFICATE_DELAY_SECONDS,
kwargs={
'student': unicode(self.user_two.id),
'course_key': unicode(self.course_two.id),
'expected_verification_status': SoftwareSecurePhotoVerification.STATUS.approved
'expected_verification_status': unicode(expected_verification_status),
}
)

View File

@@ -45,13 +45,22 @@ class GenerateUserCertificateTest(TestCase):
course_key = 'course-v1:edX+CS101+2017_T2'
student = UserFactory()
expected_verification_status = {
'status': 'approved',
'error': '',
'should_display': True,
}
kwargs = {
'student': student.id,
'course_key': course_key,
'expected_verification_status': 'approved'
'expected_verification_status': expected_verification_status,
}
user_status_mock.side_effect = [('pending', ''), ('approved', '')]
user_status_mock.side_effect = [
{'status': 'pending', 'error': '', 'should_display': True},
{'status': 'approved', 'error': '', 'should_display': True}
]
generate_certificate.apply_async(kwargs=kwargs).get()

View File

@@ -92,7 +92,7 @@ def checkout_receipt(request):
'page_title': page_title,
'is_payment_complete': is_payment_complete,
'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
'verified': IDVerificationService.verification_valid_or_pending(request.user).exists(),
'verified': IDVerificationService.user_has_valid_or_pending(request.user),
'error_summary': error_summary,
'error_text': error_text,
'for_help_text': for_help_text,

View File

@@ -627,8 +627,8 @@ class VerificationDeadlineDate(DateSummary):
@lazy
def verification_status(self):
"""Return the verification status for this user."""
status, _ = IDVerificationService.user_status(self.user)
return status
verification_status = IDVerificationService.user_status(self.user)
return verification_status['status']
def must_retry(self):
"""Return True if the user must re-submit verification, False otherwise."""

View File

@@ -396,7 +396,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
RequestCache.clear_request_cache()
expected_query_count = 41
expected_query_count = 42
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
with check_mongo_calls(mongo_count):
with self.assertNumQueries(expected_query_count):
@@ -1999,7 +1999,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
'failed': 3,
'skipped': 2
}
with self.assertNumQueries(106):
with self.assertNumQueries(114):
self.assertCertificatesGenerated(task_input, expected_results)
expected_results = {

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-04-27 16:27
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('verify_student', '0008_populate_idverificationaggregate'),
]
operations = [
migrations.RemoveField(
model_name='idverificationaggregate',
name='content_type',
),
migrations.RemoveField(
model_name='idverificationaggregate',
name='user',
),
migrations.DeleteModel(
name='IDVerificationAggregate',
),
]

View File

@@ -120,30 +120,24 @@ class IDVerificationAttempt(StatusModel):
days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]
return self.created_at + timedelta(days=days_good_for)
def should_display_status_to_user(self):
"""Whether or not the status from this attempt should be displayed to the user."""
raise NotImplementedError
class IDVerificationAggregate(IDVerificationAttempt):
"""
IDVerificationAggregate is the source of truth for all instances of IDVerificationAttempt. This
includes all types of verification, including PhotoVerification and SSOVerification. A generic
relation is used to refer to the appropriate Model object.
"""
content_type = models.ForeignKey(ContentType)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
def active_at_datetime(self, deadline):
"""Check whether the verification was active at a particular datetime.
# override these fields so we can set the value
created_at = models.DateTimeField(db_index=True)
updated_at = models.DateTimeField(db_index=True)
Arguments:
deadline (datetime): The date at which the verification was active
(created before and expiration datetime is after today).
class Meta(object):
app_label = "verify_student"
ordering = ['-created_at']
Returns:
bool
def __unicode__(self):
return 'IDVerificationAggregate for {name} - type: {type}, status: {status}'.format(
name=self.name,
type=self.content_type,
status=self.status,
"""
return (
self.created_at < deadline and
self.expiration_datetime > datetime.now(pytz.UTC)
)
@@ -188,6 +182,10 @@ class SSOVerification(IDVerificationAttempt):
status=self.status,
)
def should_display_status_to_user(self):
"""Whether or not the status from this attempt should be displayed to the user."""
return False
class PhotoVerification(IDVerificationAttempt):
"""
@@ -281,22 +279,6 @@ class PhotoVerification(IDVerificationAttempt):
abstract = True
ordering = ['-created_at']
def active_at_datetime(self, deadline):
"""Check whether the verification was active at a particular datetime.
Arguments:
deadline (datetime): The date at which the verification was active
(created before and expiration datetime is after today).
Returns:
bool
"""
return (
self.created_at < deadline and
self.expiration_datetime > datetime.now(pytz.UTC)
)
def parsed_error_msg(self):
"""
Sometimes, the error message we've received needs to be parsed into
@@ -873,6 +855,10 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
return response
def should_display_status_to_user(self):
"""Whether or not the status from this attempt should be displayed to the user."""
return True
class VerificationDeadline(TimeStampedModel):
"""

View File

@@ -4,6 +4,7 @@ Implementation of abstraction layer for other parts of the system to make querie
import logging
from itertools import chain
from django.conf import settings
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
@@ -12,8 +13,8 @@ from course_modes.models import CourseMode
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from student.models import User
from .models import SoftwareSecurePhotoVerification
from .utils import earliest_allowed_verification_date
from .models import SoftwareSecurePhotoVerification, SSOVerification
from .utils import earliest_allowed_verification_date, most_recent_verification
log = logging.getLogger(__name__)
@@ -61,121 +62,84 @@ class IDVerificationService(object):
This will check for the user's *initial* verification.
"""
return cls.verified_query(earliest_allowed_date).filter(user=user).exists()
filter_kwargs = {
'user': user,
'status': 'approved',
'created_at__gte': (earliest_allowed_date or earliest_allowed_verification_date())
}
@classmethod
def verified_query(cls, earliest_allowed_date=None):
"""
Return a query set for all records with 'approved' state
that are still valid according to the earliest_allowed_date
value or policy settings.
"""
return SoftwareSecurePhotoVerification.objects.filter(
status="approved",
created_at__gte=(earliest_allowed_date or earliest_allowed_verification_date()),
)
return (SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or
SSOVerification.objects.filter(**filter_kwargs).exists())
@classmethod
def verifications_for_user(cls, user):
"""
Return a query set for all records associated with the given user.
Return a list of all verifications associated with the given user.
"""
return SoftwareSecurePhotoVerification.objects.filter(user=user)
verifications = []
for verification in chain(SoftwareSecurePhotoVerification.objects.filter(user=user),
SSOVerification.objects.filter(user=user)):
verifications.append(verification)
return verifications
@classmethod
def get_verified_users(cls, users):
"""
Return the list of user ids that have non expired verifications from the given list of users.
Return the list of users that have non-expired verifications of either type from
the given list of users.
"""
return cls.verified_query().filter(user__in=users).select_related('user')
filter_kwargs = {
'user__in': users,
'status': 'approved',
'created_at__gte': (earliest_allowed_verification_date())
}
return chain(
SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).select_related('user'),
SSOVerification.objects.filter(**filter_kwargs).select_related('user')
)
@classmethod
def verification_valid_or_pending(cls, user, earliest_allowed_date=None, queryset=None):
def get_expiration_datetime(cls, user, statuses):
"""
Check whether the user has a complete verification attempt that is
or *might* be good. This means that it's approved, been submitted,
or would have been submitted but had an non-user error when it was
being submitted.
It's basically any situation in which the user has signed off on
the contents of the attempt, and we have not yet received a denial.
This will check for the user's *initial* verification.
Arguments:
user:
earliest_allowed_date: earliest allowed date given in the
settings
queryset: If a queryset is provided, that will be used instead
of hitting the database.
Returns:
queryset: queryset of 'PhotoVerification' sorted by 'created_at' in
descending order.
"""
valid_statuses = ['submitted', 'approved', 'must_retry']
if queryset is None:
queryset = SoftwareSecurePhotoVerification.objects.filter(user=user)
return queryset.filter(
status__in=valid_statuses,
created_at__gte=(
earliest_allowed_date
or earliest_allowed_verification_date()
)
).order_by('-created_at')
@classmethod
def get_expiration_datetime(cls, user, queryset=None):
"""
Check whether the user has an approved verification and return the
"expiration_datetime" of most recent "approved" verification.
Check whether the user has a verification with one of the given
statuses and return the "expiration_datetime" of most recent verification that
matches one of the given statuses.
Arguments:
user (Object): User
queryset: If a queryset is provided, that will be used instead
of hitting the database.
statuses: List of verification statuses (e.g., ['approved'])
Returns:
expiration_datetime: expiration_datetime of most recent "approved"
verification.
expiration_datetime: expiration_datetime of most recent verification that
matches one of the given statuses.
"""
if queryset is None:
queryset = SoftwareSecurePhotoVerification.objects.filter(user=user)
filter_kwargs = {
'user': user,
'status__in': statuses,
}
photo_verification = queryset.filter(status='approved').first()
if photo_verification:
return photo_verification.expiration_datetime
photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs)
sso_id_verifications = SSOVerification.objects.filter(**filter_kwargs)
attempt = most_recent_verification(photo_id_verifications, sso_id_verifications, 'updated_at')
return attempt and attempt.expiration_datetime
@classmethod
def user_has_valid_or_pending(cls, user, earliest_allowed_date=None, queryset=None):
def user_has_valid_or_pending(cls, user):
"""
Check whether the user has an active or pending verification attempt
Returns:
bool: True or False according to existence of valid verifications
"""
return cls.verification_valid_or_pending(user, earliest_allowed_date, queryset).exists()
filter_kwargs = {
'user': user,
'status__in': ['submitted', 'approved', 'must_retry'],
'created_at__gte': earliest_allowed_verification_date()
}
@classmethod
def active_for_user(cls, user):
"""
Return the most recent PhotoVerification that is marked ready (i.e. the
user has said they're set, but we haven't submitted anything yet).
This checks for the original verification.
"""
# This should only be one at the most, but just in case we create more
# by mistake, we'll grab the most recently created one.
active_attempts = SoftwareSecurePhotoVerification.objects.filter(
user=user,
status='ready'
).order_by('-created_at')
if active_attempts:
return active_attempts[0]
else:
return None
return (SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or
SSOVerification.objects.filter(**filter_kwargs).exists())
@classmethod
def user_status(cls, user):
@@ -188,46 +152,56 @@ class IDVerificationService(object):
If the verification process is still ongoing, returns 'pending'
If the verification has been denied and the user must resubmit photos, returns 'must_reverify'
This checks initial verifications
This checks most recent verification
"""
status = 'none'
error_msg = ''
# should_display only refers to displaying the verification attempt status to a user
# once a verification attempt has been made, otherwise we will display a prompt to complete ID verification.
user_status = {
'status': 'none',
'error': '',
'should_display': True,
}
if cls.user_is_verified(user):
status = 'approved'
# We need to check the user's most recent attempt.
try:
photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-updated_at')
sso_id_verifications = SSOVerification.objects.filter(user=user).order_by('-updated_at')
elif cls.user_has_valid_or_pending(user):
attempt = most_recent_verification(photo_id_verifications, sso_id_verifications, 'updated_at')
except IndexError:
# The user has no verification attempts, return the default set of data.
return user_status
if not attempt:
return user_status
user_status['should_display'] = attempt.should_display_status_to_user()
if attempt.created_at < earliest_allowed_verification_date():
if user_status['should_display']:
user_status['status'] = 'expired'
user_status['error'] = _("Your {platform_name} verification has expired.").format(
platform_name=configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
)
else:
# If we have a verification attempt that never would have displayed to the user,
# and that attempt is expired, then we should treat it as if the user had never verified.
return user_status
# If someone is denied their original verification attempt, they can try to reverify.
elif attempt.status == 'denied':
user_status['status'] = 'must_reverify'
if hasattr(attempt, 'error_msg') and attempt.error_msg:
user_status['error'] = attempt.parsed_error_msg()
elif attempt.status == 'approved':
user_status['status'] = 'approved'
elif attempt.status in ['submitted', 'approved', 'must_retry']:
# user_has_valid_or_pending does include 'approved', but if we are
# here, we know that the attempt is still pending
status = 'pending'
user_status['status'] = 'pending'
else:
# we need to check the most recent attempt to see if we need to ask them to do
# a retry
try:
attempts = SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-updated_at')
attempt = attempts[0]
except IndexError:
# we return 'none'
return ('none', error_msg)
if attempt.created_at < earliest_allowed_verification_date():
return (
'expired',
_("Your {platform_name} verification has expired.").format(
platform_name=configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
)
)
# If someone is denied their original verification attempt, they can try to reverify.
if attempt.status == 'denied':
status = 'must_reverify'
if attempt.error_msg:
error_msg = attempt.parsed_error_msg()
return (status, error_msg)
return user_status
@classmethod
def verification_status_for_user(cls, user, user_enrollment_mode, user_is_verified=None):

View File

@@ -8,6 +8,7 @@ import mock
import pytz
import requests.exceptions
from django.conf import settings
from django.test import TestCase
from freezegun import freeze_time
from mock import patch
from nose.tools import ( # pylint: disable=no-name-in-module
@@ -21,6 +22,7 @@ from testfixtures import LogCapture
from common.test.utils import MockS3Mixin
from lms.djangoapps.verify_student.models import (
SoftwareSecurePhotoVerification,
SSOVerification,
VerificationDeadline,
VerificationException
)
@@ -95,11 +97,38 @@ def mock_software_secure_post_unavailable(url, headers=None, data=None, **kwargs
raise requests.exceptions.ConnectionError
class TestVerification(TestCase):
"""
Common tests across all types of Verications (e.g., SoftwareSecurePhotoVerication, SSOVerification)
"""
def verification_active_at_datetime(self, attempt):
"""
Tests to ensure the Verification is active or inactive at the appropriate datetimes.
"""
# Not active before the created date
before = attempt.created_at - timedelta(seconds=1)
self.assertFalse(attempt.active_at_datetime(before))
# Active immediately after created date
after_created = attempt.created_at + timedelta(seconds=1)
self.assertTrue(attempt.active_at_datetime(after_created))
# Active immediately before expiration date
expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
before_expiration = expiration - timedelta(seconds=1)
self.assertTrue(attempt.active_at_datetime(before_expiration))
# Not active after the expiration date
attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
attempt.save()
self.assertFalse(attempt.active_at_datetime(datetime.now(pytz.UTC) + timedelta(days=1)))
# Lots of patching to stub in our own settings, and HTTP posting
@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS)
@patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post)
@ddt.ddt
class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase):
class TestPhotoVerification(TestVerification, MockS3Mixin, ModuleStoreTestCase):
def setUp(self):
super(TestPhotoVerification, self).setUp()
@@ -252,24 +281,7 @@ class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase):
def test_active_at_datetime(self):
user = UserFactory.create()
attempt = SoftwareSecurePhotoVerification.objects.create(user=user)
# Not active before the created date
before = attempt.created_at - timedelta(seconds=1)
self.assertFalse(attempt.active_at_datetime(before))
# Active immediately after created date
after_created = attempt.created_at + timedelta(seconds=1)
self.assertTrue(attempt.active_at_datetime(after_created))
# Active immediately before expiration date
expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
before_expiration = expiration - timedelta(seconds=1)
self.assertTrue(attempt.active_at_datetime(before_expiration))
# Not active after the expiration date
attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
attempt.save()
self.assertFalse(attempt.active_at_datetime(datetime.now(pytz.UTC) + timedelta(days=1)))
self.verification_active_at_datetime(attempt)
def test_initial_verification_for_user(self):
"""Test that method 'get_initial_verification' of model
@@ -364,6 +376,16 @@ class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase):
self.assertFalse(attempt.retire_user(user_id=47))
class SSOVerificationTest(TestVerification):
"""
Tests for the SSOVerification model
"""
def test_active_at_datetime(self):
user = UserFactory.create()
attempt = SSOVerification.objects.create(user=user)
self.verification_active_at_datetime(attempt)
class VerificationDeadlineTest(CacheIsolationTestCase):
"""
Tests for the VerificationDeadline model.

View File

@@ -16,7 +16,7 @@ from nose.tools import (
)
from common.test.utils import MockS3Mixin
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
from lms.djangoapps.verify_student.services import IDVerificationService
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@@ -34,52 +34,6 @@ class TestIDVerificationService(MockS3Mixin, ModuleStoreTestCase):
Tests for IDVerificationService.
"""
def test_active_for_user(self):
"""
Make sure we can retrive a user's active (in progress) verification
attempt.
"""
user = UserFactory.create()
# This user has no active at the moment...
assert_is_none(IDVerificationService.active_for_user(user))
# Create an attempt and mark it ready...
attempt = SoftwareSecurePhotoVerification(user=user)
attempt.mark_ready()
assert_equals(attempt, IDVerificationService.active_for_user(user))
# A new user won't see this...
user2 = UserFactory.create()
user2.save()
assert_is_none(IDVerificationService.active_for_user(user2))
# If it's got a different status, it doesn't count
for status in ["submitted", "must_retry", "approved", "denied"]:
attempt.status = status
attempt.save()
assert_is_none(IDVerificationService.active_for_user(user))
# But if we create yet another one and mark it ready, it passes again.
attempt_2 = SoftwareSecurePhotoVerification(user=user)
attempt_2.mark_ready()
assert_equals(attempt_2, IDVerificationService.active_for_user(user))
# And if we add yet another one with a later created time, we get that
# one instead. We always want the most recent attempt marked ready()
attempt_3 = SoftwareSecurePhotoVerification(
user=user,
created_at=attempt_2.created_at + timedelta(days=1)
)
attempt_3.save()
# We haven't marked attempt_3 ready yet, so attempt_2 still wins
assert_equals(attempt_2, IDVerificationService.active_for_user(user))
# Now we mark attempt_3 ready and expect it to come back
attempt_3.mark_ready()
assert_equals(attempt_3, IDVerificationService.active_for_user(user))
def test_user_is_verified(self):
"""
Test to make sure we correctly answer whether a user has been verified.
@@ -123,26 +77,31 @@ class TestIDVerificationService(MockS3Mixin, ModuleStoreTestCase):
# test for correct status when no error returned
user = UserFactory.create()
status = IDVerificationService.user_status(user)
self.assertEquals(status, ('none', ''))
self.assertEquals(status, {'status': 'none', 'error': '', 'should_display': True})
# test for when one has been created
attempt = SoftwareSecurePhotoVerification.objects.create(user=user, status='approved')
# test for when photo verification has been created
SoftwareSecurePhotoVerification.objects.create(user=user, status='approved')
status = IDVerificationService.user_status(user)
self.assertEquals(status, ('approved', ''))
self.assertEquals(status, {'status': 'approved', 'error': '', 'should_display': True})
# create another one for the same user, make sure the right one is
# returned
# create another photo verification for the same user, make sure the denial
# is handled properly
SoftwareSecurePhotoVerification.objects.create(
user=user, status='denied', error_msg='[{"photoIdReasons": ["Not provided"]}]'
)
status = IDVerificationService.user_status(user)
self.assertEquals(status, ('approved', ''))
self.assertEquals(status, {'status': 'must_reverify', 'error': ['id_image_missing'], 'should_display': True})
# now delete the first one and verify that the denial is being handled
# properly
attempt.delete()
# test for when sso verification has been created
SSOVerification.objects.create(user=user, status='approved')
status = IDVerificationService.user_status(user)
self.assertEquals(status, ('must_reverify', ['id_image_missing']))
self.assertEquals(status, {'status': 'approved', 'error': '', 'should_display': False})
# create another sso verification for the same user, make sure the denial
# is handled properly
SSOVerification.objects.create(user=user, status='denied')
status = IDVerificationService.user_status(user)
self.assertEquals(status, {'status': 'must_reverify', 'error': '', 'should_display': False})
@ddt.unpack
@ddt.data(

View File

@@ -5,13 +5,14 @@ Tests for verify_student utility functions.
from datetime import datetime, timedelta
import ddt
import unittest
import pytz
from mock import patch
from pytest import mark
from django.conf import settings
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from lms.djangoapps.verify_student.utils import verification_for_datetime
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
from lms.djangoapps.verify_student.utils import verification_for_datetime, most_recent_verification
from student.tests.factories import UserFactory
FAKE_SETTINGS = {
@@ -19,6 +20,7 @@ FAKE_SETTINGS = {
}
@ddt.ddt
@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS)
@mark.django_db
class TestVerifyStudentUtils(unittest.TestCase):
@@ -83,3 +85,46 @@ class TestVerifyStudentUtils(unittest.TestCase):
query = SoftwareSecurePhotoVerification.objects.filter(user=user)
result = verification_for_datetime(deadline, query)
self.assertEqual(result, second_attempt)
@ddt.data(
(False, False, None, None),
(True, False, None, 'photo'),
(False, True, None, 'sso'),
(True, True, 'photo', 'sso'),
(True, True, 'sso', 'photo'),
)
@ddt.unpack
def test_most_recent_verification(
self,
create_photo_verification,
create_sso_verification,
first_verification,
expected_verification):
user = UserFactory.create()
photo_verification = None
sso_verification = None
if not first_verification:
if create_photo_verification:
photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user)
if create_sso_verification:
sso_verification = SSOVerification.objects.create(user=user)
elif first_verification == 'photo':
photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user)
sso_verification = SSOVerification.objects.create(user=user)
else:
sso_verification = SSOVerification.objects.create(user=user)
photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user)
most_recent = most_recent_verification(
SoftwareSecurePhotoVerification.objects.all(),
SSOVerification.objects.all(),
'created_at'
)
if not expected_verification:
self.assertEqual(most_recent, None)
elif expected_verification == 'photo':
self.assertEqual(most_recent, photo_verification)
else:
self.assertEqual(most_recent, sso_verification)

View File

@@ -95,3 +95,30 @@ def send_verification_status_email(context):
subject=context['subject'],
email=context['email']
))
def most_recent_verification(photo_id_verifications, sso_id_verifications, most_recent_key):
"""
Return the most recent verification given querysets for both photo and sso verifications.
Arguments:
photo_id_verifications: Queryset containing photo verifications
sso_id_verifications: Queryset containing sso verifications
most_recent_key: Either 'updated_at' or 'created_at'
Returns:
The most recent verification.
"""
photo_id_verification = photo_id_verifications and photo_id_verifications.first()
sso_id_verification = sso_id_verifications and sso_id_verifications.first()
if not photo_id_verification and not sso_id_verification:
return None
elif photo_id_verification and not sso_id_verification:
return photo_id_verification
elif sso_id_verification and not photo_id_verification:
return sso_id_verification
elif getattr(photo_id_verification, most_recent_key) > getattr(sso_id_verification, most_recent_key):
return photo_id_verification
else:
return sso_id_verification

View File

@@ -646,11 +646,13 @@ class PayAndVerifyView(View):
Returns:
datetime object in string format
"""
photo_verifications = IDVerificationService.verification_valid_or_pending(user)
expiration_datetime = IDVerificationService.get_expiration_datetime(
user, ['submitted', 'approved', 'must_retry']
)
# return 'expiration_datetime' of latest photo verification if found,
# otherwise implicitly return ''
if photo_verifications:
return photo_verifications[0].expiration_datetime.strftime(date_format)
if expiration_datetime:
return expiration_datetime.strftime(date_format)
return ''
@@ -1226,9 +1228,9 @@ class ReverifyView(View):
Most of the work is done client-side by composing the same
Backbone views used in the initial verification flow.
"""
status, __ = IDVerificationService.user_status(request.user)
verification_status = IDVerificationService.user_status(request.user)
expiration_datetime = IDVerificationService.get_expiration_datetime(request.user)
expiration_datetime = IDVerificationService.get_expiration_datetime(request.user, ['approved'])
can_reverify = False
if expiration_datetime:
if is_verification_expiring_soon(expiration_datetime):
@@ -1243,7 +1245,7 @@ class ReverifyView(View):
# A photo verification is marked as 'pending' if its status is either
# 'submitted' or 'must_retry'.
if status in ["none", "must_reverify", "expired", "pending"] or can_reverify:
if verification_status['status'] in ["none", "must_reverify", "expired", "pending"] or can_reverify:
context = {
"user_full_name": request.user.profile.name,
"platform_name": configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
@@ -1252,6 +1254,6 @@ class ReverifyView(View):
return render_to_response("verify_student/reverify.html", context)
else:
context = {
"status": status
"status": verification_status['status']
}
return render_to_response("verify_student/reverify_not_allowed.html", context)

View File

@@ -399,7 +399,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
<%include file="_dashboard_show_consent.html" args="course_overview=course_overview, course_target=course_target, enrollment=enrollment, enterprise_customer_name=enterprise_customer_name"/>
%endif
% if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY]:
% if verification_status.get('should_display') and verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY]:
<div class="message message-status wrapper-message-primary is-shown">
% if verification_status['status'] == VERIFY_STATUS_NEED_TO_VERIFY:
<div class="verification-reminder">

View File

@@ -5,42 +5,44 @@ from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
%>
%if verification_status == 'approved':
<li class="status status-verification is-accepted">
<span class="title status-title">${_("Current Verification Status: Approved")}</span>
<p class="status-note">${_("Your edX verification has been approved. Your verification is effective for one year after submission.")}</p>
</li>
%elif verification_status == 'pending':
<li class="status status-verification is-pending">
<span class="title status-title">${_("Current Verification Status: Pending")}</span>
<p class="status-note">${_("Your edX ID verification is pending. Your verification information has been submitted and will be reviewed shortly.")}</p>
</li>
%elif verification_status in ['denied','must_reverify', 'must_retry']:
<li class="status status-verification is-denied">
<span class="title status-title">${_("Current Verification Status: Denied")}</span>
<p class="status-note">
${_("Your verification submission was not accepted. To receive a verified certificate, you must submit a new photo of yourself and your government-issued photo ID before the verification deadline for your course.")}
%if verification_display:
%if verification_status == 'approved':
<li class="status status-verification is-accepted">
<span class="title status-title">${_("Current Verification Status: Approved")}</span>
<p class="status-note">${_("Your edX verification has been approved. Your verification is effective for one year after submission.")}</p>
</li>
%elif verification_status == 'pending':
<li class="status status-verification is-pending">
<span class="title status-title">${_("Current Verification Status: Pending")}</span>
<p class="status-note">${_("Your edX ID verification is pending. Your verification information has been submitted and will be reviewed shortly.")}</p>
</li>
%elif verification_status in ['denied','must_reverify', 'must_retry']:
<li class="status status-verification is-denied">
<span class="title status-title">${_("Current Verification Status: Denied")}</span>
<p class="status-note">
${_("Your verification submission was not accepted. To receive a verified certificate, you must submit a new photo of yourself and your government-issued photo ID before the verification deadline for your course.")}
%if verification_errors:
<br><br>
${_("Your verification was denied for the following reasons:")}<br>
<ul>
%for error in verification_errors:
<li>${error}</li>
%endfor
</ul>
%endif
</p>
<div class="btn-reverify">
<a href="${reverse('verify_student_reverify')}" class="action action-reverify">${_("Resubmit Verification")}</a>
</div>
</li>
%elif verification_status == 'expired':
<li class="status status-verification is-denied">
<span class="title status-title">${_("Current Verification Status: Expired")}</span>
<p class="status-note">${_("Your verification has expired. To receive a verified certificate, you must submit a new photo of yourself and your government-issued photo ID before the verification deadline for your course.")}</p>
%if verification_errors:
<br><br>
${_("Your verification was denied for the following reasons:")}<br>
<ul>
%for error in verification_errors:
<li>${error}</li>
%endfor
</ul>
%endif
</p>
<div class="btn-reverify">
<a href="${reverse('verify_student_reverify')}" class="action action-reverify">${_("Resubmit Verification")}</a>
</div>
</li>
</li>
%elif verification_status == 'expired':
<li class="status status-verification is-denied">
<span class="title status-title">${_("Current Verification Status: Expired")}</span>
<p class="status-note">${_("Your verification has expired. To receive a verified certificate, you must submit a new photo of yourself and your government-issued photo ID before the verification deadline for your course.")}</p>
<div class="btn-reverify">
<a href="${reverse('verify_student_reverify')}" class="action action-reverify">${_("Resubmit Verification")}</a>
</div>
</li>
%endif
%endif

View File

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.utils.timezone import now
from rest_framework import serializers
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
from .models import UserPreference
@@ -94,9 +94,9 @@ class CountryTimeZoneSerializer(serializers.Serializer): # pylint: disable=abst
description = serializers.CharField()
class SoftwareSecurePhotoVerificationSerializer(serializers.ModelSerializer):
class IDVerificationSerializer(serializers.ModelSerializer):
"""
Serializer that generates a representation of a user's photo verification status.
Serializer that generates a representation of a user's ID verification status.
"""
is_verified = serializers.SerializerMethodField()
@@ -106,6 +106,16 @@ class SoftwareSecurePhotoVerificationSerializer(serializers.ModelSerializer):
"""
return obj.status == 'approved' and obj.expiration_datetime > now()
class SoftwareSecurePhotoVerificationSerializer(IDVerificationSerializer):
class Meta(object):
fields = ('status', 'expiration_datetime', 'is_verified')
model = SoftwareSecurePhotoVerification
class SSOVerificationSerializer(IDVerificationSerializer):
class Meta(object):
fields = ('status', 'expiration_datetime', 'is_verified')
model = SSOVerification

View File

@@ -14,7 +14,7 @@ from .accounts.views import (
DeactivateLogoutView
)
from .preferences.views import PreferencesDetailView, PreferencesView
from .verification_api.views import PhotoVerificationStatusView
from .verification_api.views import IDVerificationStatusView
from .validation.views import RegistrationValidationView
ME = AccountViewSet.as_view({
@@ -81,7 +81,7 @@ urlpatterns = [
),
url(
r'^v1/accounts/{}/verification_status/$'.format(settings.USERNAME_PATTERN),
PhotoVerificationStatusView.as_view(),
IDVerificationStatusView.as_view(),
name='verification_status'
),
url(

View File

@@ -5,23 +5,38 @@ from rest_framework.authentication import SessionAuthentication
from rest_framework.generics import RetrieveAPIView
from rest_framework_oauth.authentication import OAuth2Authentication
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from openedx.core.djangoapps.user_api.serializers import SoftwareSecurePhotoVerificationSerializer
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
from lms.djangoapps.verify_student.utils import most_recent_verification
from openedx.core.djangoapps.user_api.serializers import (
SoftwareSecurePhotoVerificationSerializer, SSOVerificationSerializer,
)
from openedx.core.lib.api.permissions import IsStaffOrOwner
class PhotoVerificationStatusView(RetrieveAPIView):
""" PhotoVerificationStatus detail endpoint. """
class IDVerificationStatusView(RetrieveAPIView):
""" IDVerificationStatus detail endpoint. """
authentication_classes = (JwtAuthentication, OAuth2Authentication, SessionAuthentication,)
permission_classes = (IsStaffOrOwner,)
serializer_class = SoftwareSecurePhotoVerificationSerializer
def get_serializer(self, *args, **kwargs):
"""
Overrides default get_serializer in order to choose the correct serializer for the instance.
"""
instance = args[0]
kwargs['context'] = self.get_serializer_context()
if isinstance(instance, SoftwareSecurePhotoVerification):
return SoftwareSecurePhotoVerificationSerializer(*args, **kwargs)
else:
return SSOVerificationSerializer(*args, **kwargs)
def get_object(self):
username = self.kwargs['username']
verifications = SoftwareSecurePhotoVerification.objects.filter(user__username=username).order_by('-updated_at')
photo_verifications = SoftwareSecurePhotoVerification.objects.filter(
user__username=username).order_by('-updated_at')
sso_verifications = SSOVerification.objects.filter(user__username=username).order_by('-updated_at')
if len(verifications) > 0:
verification = verifications[0]
if photo_verifications or sso_verifications:
verification = most_recent_verification(photo_verifications, sso_verifications, 'updated_at')
self.check_object_permissions(self.request, verification)
return verification