Adds retry logic for software secure
This commit is contained in:
@@ -1899,6 +1899,12 @@ derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION')
|
||||
RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5
|
||||
RETRY_ACTIVATION_EMAIL_TIMEOUT = 0.5
|
||||
|
||||
# Software Secure request retry settings
|
||||
# Time in seconds before a retry of the task should be 60 mints.
|
||||
SOFTWARE_SECURE_REQUEST_RETRY_DELAY = 60 * 60
|
||||
# Maximum of 6 retries before giving up.
|
||||
SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS = 6
|
||||
|
||||
############## DJANGO-USER-TASKS ##############
|
||||
|
||||
# How long until database records about the outcome of a task and its artifacts get deleted?
|
||||
@@ -1951,6 +1957,8 @@ RECALCULATE_GRADES_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE
|
||||
# Queue to use for updating grades due to grading policy change
|
||||
POLICY_CHANGE_GRADES_ROUTING_KEY = 'edx.lms.core.default'
|
||||
|
||||
SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = 'edx.lms.core.default'
|
||||
|
||||
# Rate limit for regrading tasks that a grading policy change can kick off
|
||||
POLICY_CHANGE_TASK_RATE_LIMIT = '300/h'
|
||||
|
||||
|
||||
@@ -504,6 +504,11 @@ POLICY_CHANGE_GRADES_ROUTING_KEY = ENV_TOKENS.get('POLICY_CHANGE_GRADES_ROUTING_
|
||||
# Rate limit for regrading tasks that a grading policy change can kick off
|
||||
POLICY_CHANGE_TASK_RATE_LIMIT = ENV_TOKENS.get('POLICY_CHANGE_TASK_RATE_LIMIT', POLICY_CHANGE_TASK_RATE_LIMIT)
|
||||
|
||||
SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = ENV_TOKENS.get(
|
||||
'SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY',
|
||||
HIGH_PRIORITY_QUEUE
|
||||
)
|
||||
|
||||
# Event tracking
|
||||
TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
|
||||
EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {}))
|
||||
|
||||
@@ -32,7 +32,7 @@ from course_modes.models import CourseMode
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
from lms.djangoapps.certificates.models import CertificateStatuses # pylint: disable=import-error
|
||||
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error
|
||||
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
|
||||
from lms.djangoapps.verify_student.tests import TestVerificationBase
|
||||
from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory
|
||||
from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory, ProgramFactory, generate_course_run_key
|
||||
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
|
||||
@@ -291,7 +291,7 @@ class CourseEndingTest(TestCase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class DashboardTest(ModuleStoreTestCase):
|
||||
class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
|
||||
"""
|
||||
Tests for dashboard utility functions
|
||||
"""
|
||||
@@ -314,9 +314,7 @@ class DashboardTest(ModuleStoreTestCase):
|
||||
|
||||
if mode == 'verified':
|
||||
# Simulate a successful verification attempt
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt = self.create_and_submit_attempt_for_user(self.user)
|
||||
attempt.approve()
|
||||
|
||||
response = self.client.get(reverse('dashboard'))
|
||||
@@ -351,9 +349,7 @@ class DashboardTest(ModuleStoreTestCase):
|
||||
|
||||
if mode == 'verified':
|
||||
# Simulate a successful verification attempt
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt = self.create_and_submit_attempt_for_user(self.user)
|
||||
attempt.approve()
|
||||
|
||||
response = self.client.get(reverse('dashboard'))
|
||||
|
||||
@@ -4,17 +4,14 @@ Tests for django admin commands in the verify_student module
|
||||
Lots of imports from verify_student's model tests, since they cover similar ground
|
||||
"""
|
||||
|
||||
|
||||
import boto
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
from mock import patch
|
||||
from testfixtures import LogCapture
|
||||
|
||||
from common.test.utils import MockS3BotoMixin
|
||||
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSPVerificationRetryConfig
|
||||
from lms.djangoapps.verify_student.tests import TestVerificationBase
|
||||
from lms.djangoapps.verify_student.tests.test_models import (
|
||||
FAKE_SETTINGS,
|
||||
mock_software_secure_post,
|
||||
@@ -28,22 +25,10 @@ LOGGER_NAME = 'retry_photo_verification'
|
||||
# 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)
|
||||
class TestVerifyStudentCommand(MockS3BotoMixin, TestCase):
|
||||
class TestVerifyStudentCommand(MockS3BotoMixin, TestVerificationBase):
|
||||
"""
|
||||
Tests for django admin commands in the verify_student module
|
||||
"""
|
||||
def create_and_submit(self, username):
|
||||
"""
|
||||
Helper method that lets us create new SoftwareSecurePhotoVerifications
|
||||
"""
|
||||
user = UserFactory.create()
|
||||
attempt = SoftwareSecurePhotoVerification(user=user)
|
||||
user.profile.name = username
|
||||
attempt.upload_face_image("Fake Data")
|
||||
attempt.upload_photo_id_image("More Fake Data")
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
return attempt
|
||||
|
||||
def test_retry_failed_photo_verifications(self):
|
||||
"""
|
||||
@@ -51,55 +36,58 @@ class TestVerifyStudentCommand(MockS3BotoMixin, TestCase):
|
||||
and re-submit them executes successfully
|
||||
"""
|
||||
# set up some fake data to use...
|
||||
self.create_and_submit("SuccessfulSally")
|
||||
self.create_upload_and_submit_attempt_for_user()
|
||||
with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error):
|
||||
self.create_and_submit("RetryRoger")
|
||||
with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error):
|
||||
self.create_and_submit("RetryRick")
|
||||
self.create_upload_and_submit_attempt_for_user()
|
||||
self.create_upload_and_submit_attempt_for_user()
|
||||
|
||||
# check to make sure we had two successes and two failures; otherwise we've got problems elsewhere
|
||||
assert len(SoftwareSecurePhotoVerification.objects.filter(status="submitted")) == 1
|
||||
assert len(SoftwareSecurePhotoVerification.objects.filter(status='must_retry')) == 2
|
||||
call_command('retry_failed_photo_verifications')
|
||||
self.assertEqual(SoftwareSecurePhotoVerification.objects.filter(status="submitted").count(), 1)
|
||||
self.assertEqual(SoftwareSecurePhotoVerification.objects.filter(status='must_retry').count(), 2)
|
||||
|
||||
with self.immediate_on_commit():
|
||||
call_command('retry_failed_photo_verifications')
|
||||
attempts_to_retry = SoftwareSecurePhotoVerification.objects.filter(status='must_retry')
|
||||
assert not attempts_to_retry
|
||||
|
||||
def test_args_from_database(self):
|
||||
"""Test management command arguments injected from config model."""
|
||||
# Nothing in the database, should default to disabled
|
||||
|
||||
with LogCapture(LOGGER_NAME) as log:
|
||||
call_command('retry_failed_photo_verifications', '--args-from-database')
|
||||
log.check_present(
|
||||
(
|
||||
LOGGER_NAME, 'WARNING',
|
||||
u"SSPVerificationRetryConfig is disabled or empty, but --args-from-database was requested."
|
||||
),
|
||||
)
|
||||
# Add a config
|
||||
def add_test_config_for_retry_verification(self):
|
||||
"""Setups verification retry configuration."""
|
||||
config = SSPVerificationRetryConfig.current()
|
||||
config.arguments = '--verification-ids 1 2 3'
|
||||
config.enabled = True
|
||||
config.save()
|
||||
|
||||
def test_args_from_database(self):
|
||||
"""Test management command arguments injected from config model."""
|
||||
# Nothing in the database, should default to disabled
|
||||
with LogCapture(LOGGER_NAME) as log:
|
||||
call_command('retry_failed_photo_verifications', '--args-from-database')
|
||||
log.check_present(
|
||||
(
|
||||
LOGGER_NAME, 'WARNING',
|
||||
'SSPVerificationRetryConfig is disabled or empty, but --args-from-database was requested.'
|
||||
),
|
||||
)
|
||||
self.add_test_config_for_retry_verification()
|
||||
with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error):
|
||||
self.create_and_submit("RetryRoger")
|
||||
|
||||
self.create_upload_and_submit_attempt_for_user()
|
||||
with LogCapture(LOGGER_NAME) as log:
|
||||
call_command('retry_failed_photo_verifications')
|
||||
with self.immediate_on_commit():
|
||||
call_command('retry_failed_photo_verifications')
|
||||
log.check_present(
|
||||
(
|
||||
LOGGER_NAME, 'INFO',
|
||||
'Attempting to retry {0} failed PhotoVerification submissions'.format(1)
|
||||
),
|
||||
)
|
||||
|
||||
with LogCapture(LOGGER_NAME) as log:
|
||||
with self.immediate_on_commit():
|
||||
call_command('retry_failed_photo_verifications', '--args-from-database')
|
||||
|
||||
log.check_present(
|
||||
(
|
||||
LOGGER_NAME, 'INFO',
|
||||
u"Attempting to retry {0} failed PhotoVerification submissions".format(1)
|
||||
'Fetching retry verification ids from config model'
|
||||
),
|
||||
)
|
||||
|
||||
with LogCapture(LOGGER_NAME) as log:
|
||||
call_command('retry_failed_photo_verifications', '--args-from-database')
|
||||
|
||||
log.check_present(
|
||||
(
|
||||
LOGGER_NAME, 'INFO',
|
||||
u"Fetching retry verification ids from config model"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ of a student over a period of time. Right now, the only models are the abstract
|
||||
photo verification process as generic as possible.
|
||||
"""
|
||||
|
||||
|
||||
import base64
|
||||
import codecs
|
||||
import functools
|
||||
@@ -21,13 +20,12 @@ from datetime import timedelta
|
||||
from email.utils import formatdate
|
||||
|
||||
import requests
|
||||
import simplejson
|
||||
import six
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from django.utils.functional import cached_property
|
||||
@@ -46,7 +44,7 @@ from lms.djangoapps.verify_student.ssencrypt import (
|
||||
from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED
|
||||
from openedx.core.storage import get_storage
|
||||
|
||||
from .utils import earliest_allowed_verification_date
|
||||
from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -393,7 +391,7 @@ class PhotoVerification(IDVerificationAttempt):
|
||||
# student dashboard. But at this point, we lock the value into the
|
||||
# attempt.
|
||||
self.name = self.user.profile.name
|
||||
self.status = "ready"
|
||||
self.status = self.STATUS.ready
|
||||
self.save()
|
||||
|
||||
@status_before_must_be("must_retry", "submitted", "approved", "denied")
|
||||
@@ -426,7 +424,7 @@ class PhotoVerification(IDVerificationAttempt):
|
||||
logs. This should be a relatively rare occurrence.
|
||||
"""
|
||||
# If someone approves an outdated version of this, the first one wins
|
||||
if self.status == "approved":
|
||||
if self.status == self.STATUS.approved:
|
||||
return
|
||||
|
||||
log.info(u"Verification for user '{user_id}' approved by '{reviewer}'.".format(
|
||||
@@ -436,7 +434,7 @@ class PhotoVerification(IDVerificationAttempt):
|
||||
self.error_code = "" # reset, in case this attempt was denied before
|
||||
self.reviewing_user = user_id
|
||||
self.reviewing_service = service
|
||||
self.status = "approved"
|
||||
self.status = self.STATUS.approved
|
||||
self.save()
|
||||
# Emit signal to find and generate eligible certificates
|
||||
LEARNER_NOW_VERIFIED.send_robust(
|
||||
@@ -447,6 +445,51 @@ class PhotoVerification(IDVerificationAttempt):
|
||||
message = u'LEARNER_NOW_VERIFIED signal fired for {user} from PhotoVerification'
|
||||
log.info(message.format(user=self.user.username))
|
||||
|
||||
@status_before_must_be("ready", "must_retry")
|
||||
def mark_submit(self):
|
||||
"""
|
||||
Submit this attempt.
|
||||
Valid attempt statuses when calling this method:
|
||||
`ready`, `must_retry`
|
||||
|
||||
Status after method completes: `submitted`
|
||||
|
||||
State Transitions:
|
||||
|
||||
→ → → `must_retry`
|
||||
↑ ↑ ↓
|
||||
`ready` → `submitted`
|
||||
"""
|
||||
self.submitted_at = now()
|
||||
self.status = self.STATUS.submitted
|
||||
self.save()
|
||||
|
||||
@status_before_must_be("ready", "must_retry", "submitted")
|
||||
def mark_must_retry(self, error=""):
|
||||
"""
|
||||
Set the attempt status to `must_retry`.
|
||||
Mark that this attempt could not be completed because of a system error.
|
||||
Status should be moved to `must_retry`. For example, if Software Secure
|
||||
service is down and we couldn't process the request even after retrying.
|
||||
|
||||
Valid attempt statuses when calling this method:
|
||||
`ready`, `submitted`
|
||||
|
||||
Status after method completes: `must_retry`
|
||||
|
||||
State Transitions:
|
||||
|
||||
→ → → `must_retry`
|
||||
↑ ↑ ↓
|
||||
`ready` → `submitted`
|
||||
"""
|
||||
if self.status == self.STATUS.must_retry:
|
||||
return
|
||||
|
||||
self.status = self.STATUS.must_retry
|
||||
self.error_msg = error
|
||||
self.save()
|
||||
|
||||
@status_before_must_be("must_retry", "submitted", "approved", "denied")
|
||||
def deny(self,
|
||||
error_msg,
|
||||
@@ -490,7 +533,7 @@ class PhotoVerification(IDVerificationAttempt):
|
||||
self.error_code = error_code
|
||||
self.reviewing_user = reviewing_user
|
||||
self.reviewing_service = reviewing_service
|
||||
self.status = "denied"
|
||||
self.status = self.STATUS.denied
|
||||
self.save()
|
||||
|
||||
@status_before_must_be("must_retry", "submitted", "approved", "denied")
|
||||
@@ -505,14 +548,14 @@ class PhotoVerification(IDVerificationAttempt):
|
||||
reported to us that they couldn't process our submission because they
|
||||
couldn't decrypt the image we sent.
|
||||
"""
|
||||
if self.status in ["approved", "denied"]:
|
||||
if self.status in [self.STATUS.approved, self.STATUS.denied]:
|
||||
return # If we were already approved or denied, just leave it.
|
||||
|
||||
self.error_msg = error_msg
|
||||
self.error_code = error_code
|
||||
self.reviewing_user = reviewing_user
|
||||
self.reviewing_service = reviewing_service
|
||||
self.status = "must_retry"
|
||||
self.status = self.STATUS.must_retry
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
@@ -641,7 +684,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
|
||||
# developing and aren't interested in working on student identity
|
||||
# verification functionality. If you do want to work on it, you have to
|
||||
# explicitly enable these in your private settings.
|
||||
if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
|
||||
if auto_verify_for_testing_enabled():
|
||||
return
|
||||
|
||||
aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
|
||||
@@ -671,7 +714,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
|
||||
# developing and aren't interested in working on student identity
|
||||
# verification functionality. If you do want to work on it, you have to
|
||||
# explicitly enable these in your private settings.
|
||||
if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
|
||||
if auto_verify_for_testing_enabled():
|
||||
# fake photo id key is set only for initial verification
|
||||
self.photo_id_key = 'fake-photo-id-key'
|
||||
self.save()
|
||||
@@ -703,27 +746,28 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
|
||||
|
||||
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
|
||||
data from this attempt. This is used for re-verification, in which new face photos
|
||||
are sent with previously-submitted ID photos.
|
||||
|
||||
"""
|
||||
try:
|
||||
response = self.send_request(copy_id_photo_from=copy_id_photo_from)
|
||||
if response.ok:
|
||||
self.submitted_at = now()
|
||||
self.status = "submitted"
|
||||
self.save()
|
||||
else:
|
||||
self.status = "must_retry"
|
||||
self.error_msg = response.text
|
||||
self.save()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception(
|
||||
u'Software Secure submission failed for user %s, setting status to must_retry',
|
||||
self.user.username
|
||||
from .tasks import send_request_to_ss_for_user
|
||||
if auto_verify_for_testing_enabled():
|
||||
self.mark_submit()
|
||||
fake_response = requests.Response()
|
||||
fake_response.status_code = 200
|
||||
return fake_response
|
||||
|
||||
if copy_id_photo_from is not None:
|
||||
log.info(
|
||||
('Software Secure attempt for user: %r and receipt ID: %r used the same photo ID data as the '
|
||||
'receipt with ID %r.'),
|
||||
self.user.username,
|
||||
self.receipt_id,
|
||||
copy_id_photo_from.receipt_id,
|
||||
)
|
||||
self.status = "must_retry"
|
||||
self.save()
|
||||
transaction.on_commit(
|
||||
lambda:
|
||||
send_request_to_ss_for_user.delay(user_verification_id=self.id, copy_id_photo_from=copy_id_photo_from)
|
||||
)
|
||||
|
||||
def parsed_error_msg(self):
|
||||
"""
|
||||
@@ -766,7 +810,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
|
||||
parsed_errors.append(parsed_error)
|
||||
else:
|
||||
log.debug(u'Ignoring photo verification error message: %s', message)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception(u'Failed to parse error message for SoftwareSecurePhotoVerification %d', self.pk)
|
||||
|
||||
return parsed_errors
|
||||
@@ -845,7 +889,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
|
||||
|
||||
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
|
||||
data from this attempt. This is used for re-verification, in which new face photos
|
||||
are sent with previously-submitted ID photos.
|
||||
|
||||
Returns:
|
||||
@@ -911,55 +955,6 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
|
||||
|
||||
return header_txt + "\n\n" + body_txt
|
||||
|
||||
def send_request(self, copy_id_photo_from=None):
|
||||
"""
|
||||
Assembles a submission to Software Secure and sends it via HTTPS.
|
||||
|
||||
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
|
||||
# skip posting anything to Software Secure. We actually don't even
|
||||
# create the message because that would require encryption and message
|
||||
# signing that rely on settings.VERIFY_STUDENT values that aren't set
|
||||
# in dev. So we just pretend like we successfully posted
|
||||
if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
|
||||
fake_response = requests.Response()
|
||||
fake_response.status_code = 200
|
||||
return fake_response
|
||||
|
||||
headers, body = self.create_request(copy_id_photo_from=copy_id_photo_from)
|
||||
|
||||
response = requests.post(
|
||||
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"],
|
||||
headers=headers,
|
||||
data=simplejson.dumps(body, indent=2, sort_keys=True, ensure_ascii=False).encode('utf-8'),
|
||||
verify=False
|
||||
)
|
||||
|
||||
log.info(u"Sent request to Software Secure for receipt ID %s.", self.receipt_id)
|
||||
if copy_id_photo_from is not None:
|
||||
log.info(
|
||||
(
|
||||
u"Software Secure attempt with receipt ID %s used the same photo ID "
|
||||
u"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("Body:\n{}\n\n".format(body))
|
||||
log.debug(u"Return code: {}".format(response.status_code))
|
||||
log.debug(u"Return message:\n\n{}\n\n".format(response.text))
|
||||
|
||||
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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Implementation of abstraction layer for other parts of the system to make queries related to ID Verification.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from itertools import chain
|
||||
|
||||
@@ -148,9 +147,11 @@ class IDVerificationService(object):
|
||||
'created_at__gte': earliest_allowed_verification_date()
|
||||
}
|
||||
|
||||
return (SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or
|
||||
SSOVerification.objects.filter(**filter_kwargs).exists() or
|
||||
ManualVerification.objects.filter(**filter_kwargs).exists())
|
||||
return (
|
||||
SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or
|
||||
SSOVerification.objects.filter(**filter_kwargs).exists() or
|
||||
ManualVerification.objects.filter(**filter_kwargs).exists()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def user_status(cls, user):
|
||||
|
||||
@@ -2,20 +2,70 @@
|
||||
Django Celery tasks for service status app
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from smtplib import SMTPException
|
||||
|
||||
from celery import task
|
||||
import requests
|
||||
import simplejson
|
||||
from celery import Task, task
|
||||
from celery.states import FAILURE
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
|
||||
ACE_ROUTING_KEY = getattr(settings, 'ACE_ROUTING_KEY', None)
|
||||
SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = getattr(settings, 'SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY', None)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseSoftwareSecureTask(Task):
|
||||
"""
|
||||
Base task class for use with Software Secure request.
|
||||
|
||||
Permits updating information about user attempt in correspondence to submitting
|
||||
request to software secure.
|
||||
"""
|
||||
abstract = True
|
||||
|
||||
def on_success(self, response, task_id, args, kwargs):
|
||||
"""
|
||||
Update SoftwareSecurePhotoVerification object corresponding to this
|
||||
task with info about success.
|
||||
|
||||
Updates user verification attempt to "submitted" if the response was ok otherwise
|
||||
set it to "must_retry".
|
||||
"""
|
||||
user_verification_id = kwargs['user_verification_id']
|
||||
user_verification = SoftwareSecurePhotoVerification.objects.get(id=user_verification_id)
|
||||
if response.ok:
|
||||
user_verification.mark_submit()
|
||||
log.info(
|
||||
'Sent request to Software Secure for user: %r and receipt ID %r.',
|
||||
user_verification.user.username,
|
||||
user_verification.receipt_id,
|
||||
)
|
||||
return user_verification
|
||||
|
||||
user_verification.mark_must_retry(response.text)
|
||||
|
||||
def after_return(self, status, retval, task_id, args, kwargs, einfo):
|
||||
"""
|
||||
If max retries have reached and task status is still failing, mark user submission
|
||||
with "must_retry" so that it can be retried latter.
|
||||
"""
|
||||
if self.max_retries == self.request.retries and status == FAILURE:
|
||||
user_verification_id = kwargs['user_verification_id']
|
||||
user_verification = SoftwareSecurePhotoVerification.objects.get(id=user_verification_id)
|
||||
user_verification.mark_must_retry()
|
||||
log.error(
|
||||
'Software Secure submission failed for user %r, setting status to must_retry',
|
||||
user_verification.user.username,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@task(routing_key=ACE_ROUTING_KEY)
|
||||
def send_verification_status_email(context):
|
||||
"""
|
||||
@@ -35,3 +85,48 @@ def send_verification_status_email(context):
|
||||
msg.send(fail_silently=False)
|
||||
except SMTPException:
|
||||
log.warning(u"Failure in sending verification status e-mail to %s", dest_addr)
|
||||
|
||||
|
||||
@task(
|
||||
base=BaseSoftwareSecureTask,
|
||||
bind=True,
|
||||
default_retry_delay=settings.SOFTWARE_SECURE_REQUEST_RETRY_DELAY,
|
||||
max_retries=settings.SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS,
|
||||
routing_key=SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY,
|
||||
)
|
||||
def send_request_to_ss_for_user(self, user_verification_id, copy_id_photo_from):
|
||||
"""
|
||||
Assembles a submission to Software Secure.
|
||||
|
||||
Keyword Arguments:
|
||||
user_verification_id (int) SoftwareSecurePhotoVerification model object identifier.
|
||||
copy_id_photo_from (SoftwareSecurePhotoVerification): If provided, re-send the ID photo
|
||||
data from this attempt. This is used for re-verification, in which new face photos
|
||||
are sent with previously-submitted ID photos.
|
||||
Returns:
|
||||
request.Response
|
||||
"""
|
||||
log.info('=>New Verification Task Received') # todo -- remove before merge.
|
||||
user_verification = SoftwareSecurePhotoVerification.objects.get(id=user_verification_id)
|
||||
try:
|
||||
headers, body = user_verification.create_request(copy_id_photo_from)
|
||||
response = requests.post(
|
||||
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"],
|
||||
headers=headers,
|
||||
data=simplejson.dumps(body, indent=2, sort_keys=True, ensure_ascii=False).encode('utf-8'),
|
||||
verify=False
|
||||
)
|
||||
return response
|
||||
except Exception as exc: # pylint: disable=bare-except
|
||||
log.error(
|
||||
(
|
||||
'Retrying sending request to Software Secure for user: %r, Receipt ID: %r '
|
||||
'attempt#: %s of %s'
|
||||
),
|
||||
user_verification.user.username,
|
||||
user_verification.receipt_id,
|
||||
self.request.retries,
|
||||
settings.SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS,
|
||||
)
|
||||
log.error(str(exc))
|
||||
self.retry()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
|
||||
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
class TestVerificationBase(TestCase):
|
||||
"""
|
||||
Common tests across all types of Verifications (e.g., SoftwareSecurePhotoVerification, SSOVerification)
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def immediate_on_commit(self, using=None):
|
||||
"""
|
||||
Context manager executing transaction.on_commit() hooks immediately as
|
||||
if the connection was in auto-commit mode. This is required when
|
||||
using a subclass of django.test.TestCase as all tests are wrapped in
|
||||
a transaction that never gets committed.
|
||||
|
||||
TODO: Remove when immediate_on_commit function is actually implemented
|
||||
Django Ticket #: 30456, Link: https://code.djangoproject.com/ticket/30457#no1
|
||||
"""
|
||||
immediate_using = DEFAULT_DB_ALIAS if using is None else using
|
||||
|
||||
def on_commit(func, using=None):
|
||||
using = DEFAULT_DB_ALIAS if using is None else using
|
||||
if using == immediate_using:
|
||||
func()
|
||||
|
||||
with mock.patch('django.db.transaction.on_commit', side_effect=on_commit) as patch:
|
||||
yield patch
|
||||
|
||||
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(now() + timedelta(days=1)))
|
||||
|
||||
def submit_attempt(self, attempt):
|
||||
with self.immediate_on_commit():
|
||||
attempt.submit()
|
||||
attempt.refresh_from_db()
|
||||
return attempt
|
||||
|
||||
def create_and_submit_attempt_for_user(self, user=None):
|
||||
"""
|
||||
Create photo verification attempt without uploading photos
|
||||
for a user.
|
||||
"""
|
||||
if not user:
|
||||
user = UserFactory.create()
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=user)
|
||||
attempt.mark_ready()
|
||||
return self.submit_attempt(attempt)
|
||||
|
||||
def create_upload_and_submit_attempt_for_user(self, user=None):
|
||||
"""
|
||||
Helper method to create a generic submission with photos for
|
||||
a user and send it.
|
||||
"""
|
||||
if not user:
|
||||
user = UserFactory.create()
|
||||
attempt = SoftwareSecurePhotoVerification(user=user)
|
||||
user.profile.name = u"Rust\u01B4"
|
||||
|
||||
attempt.upload_face_image("Just pretend this is image data")
|
||||
attempt.upload_photo_id_image("Hey, we're a photo ID")
|
||||
attempt.mark_ready()
|
||||
return self.submit_attempt(attempt)
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import base64
|
||||
import simplejson as json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import ddt
|
||||
import mock
|
||||
import requests.exceptions
|
||||
import simplejson as json
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
from freezegun import freeze_time
|
||||
from mock import patch
|
||||
from six.moves import range
|
||||
from student.tests.factories import UserFactory
|
||||
from testfixtures import LogCapture
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from common.test.utils import MockS3BotoMixin
|
||||
from lms.djangoapps.verify_student.models import (
|
||||
ManualVerification,
|
||||
PhotoVerification,
|
||||
SoftwareSecurePhotoVerification,
|
||||
SSOVerification,
|
||||
ManualVerification,
|
||||
VerificationException,
|
||||
VerificationException
|
||||
)
|
||||
from student.tests.factories import UserFactory
|
||||
from verify_student.tests import TestVerificationBase
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
FAKE_SETTINGS = {
|
||||
"SOFTWARE_SECURE": {
|
||||
@@ -90,39 +89,11 @@ 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(now() + 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(TestVerification, MockS3BotoMixin, ModuleStoreTestCase):
|
||||
class TestPhotoVerification(TestVerificationBase, MockS3BotoMixin, ModuleStoreTestCase):
|
||||
|
||||
def test_state_transitions(self):
|
||||
"""
|
||||
@@ -138,44 +109,59 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
"""
|
||||
user = UserFactory.create()
|
||||
attempt = SoftwareSecurePhotoVerification(user=user)
|
||||
self.assertEqual(attempt.status, "created")
|
||||
self.assertEqual(attempt.status, PhotoVerification.STATUS.created)
|
||||
|
||||
# These should all fail because we're in the wrong starting state.
|
||||
self.assertRaises(VerificationException, attempt.submit)
|
||||
self.assertRaises(VerificationException, attempt.approve)
|
||||
self.assertRaises(VerificationException, attempt.deny)
|
||||
self.assertRaises(VerificationException, attempt.mark_must_retry)
|
||||
self.assertRaises(VerificationException, attempt.mark_submit)
|
||||
|
||||
# Now let's fill in some values so that we can pass the mark_ready() call
|
||||
attempt.mark_ready()
|
||||
self.assertEqual(attempt.status, "ready")
|
||||
self.assertEqual(attempt.status, PhotoVerification.STATUS.ready)
|
||||
|
||||
# ready (can't approve or deny unless it's "submitted")
|
||||
self.assertRaises(VerificationException, attempt.approve)
|
||||
self.assertRaises(VerificationException, attempt.deny)
|
||||
attempt.mark_must_retry()
|
||||
attempt.mark_submit()
|
||||
|
||||
DENY_ERROR_MSG = '[{"photoIdReasons": ["Not provided"]}]'
|
||||
|
||||
# must_retry
|
||||
attempt.status = "must_retry"
|
||||
attempt.status = PhotoVerification.STATUS.must_retry
|
||||
attempt.system_error("System error")
|
||||
attempt.mark_must_retry() # no-op
|
||||
attempt.mark_submit()
|
||||
attempt.approve()
|
||||
attempt.status = "must_retry"
|
||||
|
||||
attempt.status = PhotoVerification.STATUS.must_retry
|
||||
attempt.deny(DENY_ERROR_MSG)
|
||||
|
||||
# submitted
|
||||
attempt.status = "submitted"
|
||||
attempt.status = PhotoVerification.STATUS.submitted
|
||||
attempt.deny(DENY_ERROR_MSG)
|
||||
attempt.status = "submitted"
|
||||
|
||||
attempt.status = PhotoVerification.STATUS.submitted
|
||||
attempt.mark_must_retry()
|
||||
|
||||
attempt.status = PhotoVerification.STATUS.submitted
|
||||
attempt.approve()
|
||||
|
||||
# approved
|
||||
self.assertRaises(VerificationException, attempt.submit)
|
||||
self.assertRaises(VerificationException, attempt.mark_must_retry)
|
||||
self.assertRaises(VerificationException, attempt.mark_submit)
|
||||
attempt.approve() # no-op
|
||||
attempt.system_error("System error") # no-op, something processed it without error
|
||||
attempt.deny(DENY_ERROR_MSG)
|
||||
|
||||
# denied
|
||||
self.assertRaises(VerificationException, attempt.submit)
|
||||
self.assertRaises(VerificationException, attempt.mark_must_retry)
|
||||
self.assertRaises(VerificationException, attempt.mark_submit)
|
||||
attempt.deny(DENY_ERROR_MSG) # no-op
|
||||
attempt.system_error("System error") # no-op, something processed it without error
|
||||
attempt.approve()
|
||||
@@ -198,39 +184,21 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
|
||||
self.assertEqual(u"Clyde \u01B4", attempt.name)
|
||||
|
||||
def create_and_submit(self):
|
||||
"""Helper method to create a generic submission and send it."""
|
||||
user = UserFactory.create()
|
||||
attempt = SoftwareSecurePhotoVerification(user=user)
|
||||
user.profile.name = u"Rust\u01B4"
|
||||
|
||||
attempt.upload_face_image("Just pretend this is image data")
|
||||
attempt.upload_photo_id_image("Hey, we're a photo ID")
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
|
||||
return attempt
|
||||
|
||||
def test_submissions(self):
|
||||
"""Test that we set our status correctly after a submission."""
|
||||
# Basic case, things go well.
|
||||
attempt = self.create_and_submit()
|
||||
self.assertEqual(attempt.status, "submitted")
|
||||
attempt = self.create_upload_and_submit_attempt_for_user()
|
||||
self.assertEqual(attempt.status, PhotoVerification.STATUS.submitted)
|
||||
|
||||
# We post, but Software Secure doesn't like what we send for some reason
|
||||
with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error):
|
||||
attempt = self.create_and_submit()
|
||||
self.assertEqual(attempt.status, "must_retry")
|
||||
with patch('lms.djangoapps.verify_student.tasks.requests.post', new=mock_software_secure_post_error):
|
||||
attempt = self.create_upload_and_submit_attempt_for_user()
|
||||
self.assertEqual(attempt.status, PhotoVerification.STATUS.must_retry)
|
||||
|
||||
# We try to post, but run into an error (in this case a network connection error)
|
||||
with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_unavailable):
|
||||
with LogCapture('lms.djangoapps.verify_student.models') as logger:
|
||||
attempt = self.create_and_submit()
|
||||
self.assertEqual(attempt.status, "must_retry")
|
||||
logger.check(
|
||||
('lms.djangoapps.verify_student.models', 'ERROR',
|
||||
u'Software Secure submission failed for user %s, setting status to must_retry'
|
||||
% attempt.user.username))
|
||||
with patch('lms.djangoapps.verify_student.tasks.requests.post', new=mock_software_secure_post_unavailable):
|
||||
attempt = self.create_upload_and_submit_attempt_for_user()
|
||||
self.assertEqual(attempt.status, PhotoVerification.STATUS.must_retry)
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
|
||||
def test_submission_while_testing_flag_is_true(self):
|
||||
@@ -238,21 +206,14 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
initial verification when the feature flag 'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'
|
||||
is enabled.
|
||||
"""
|
||||
user = UserFactory.create()
|
||||
attempt = SoftwareSecurePhotoVerification(user=user)
|
||||
user.profile.name = "test-user"
|
||||
|
||||
attempt.upload_photo_id_image("Image data")
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
|
||||
attempt = self.create_upload_and_submit_attempt_for_user()
|
||||
self.assertEqual(attempt.photo_id_key, "fake-photo-id-key")
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
def test_parse_error_msg_success(self):
|
||||
user = UserFactory.create()
|
||||
attempt = SoftwareSecurePhotoVerification(user=user)
|
||||
attempt.status = 'denied'
|
||||
attempt.status = PhotoVerification.STATUS.denied
|
||||
attempt.error_msg = '[{"userPhotoReasons": ["Face out of view"]}, {"photoIdReasons": ["Photo hidden/No photo", "ID name not provided"]}]'
|
||||
parsed_error_msg = attempt.parsed_error_msg()
|
||||
self.assertEqual(
|
||||
@@ -288,7 +249,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
|
||||
# Make an initial verification with 'photo_id_key'
|
||||
attempt = SoftwareSecurePhotoVerification(user=user, photo_id_key="dummy_photo_id_key")
|
||||
attempt.status = 'approved'
|
||||
attempt.status = PhotoVerification.STATUS.approved
|
||||
attempt.save()
|
||||
|
||||
# Check that method 'get_initial_verification' returns the correct
|
||||
@@ -298,7 +259,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
|
||||
# Now create a second verification without 'photo_id_key'
|
||||
attempt = SoftwareSecurePhotoVerification(user=user)
|
||||
attempt.status = 'submitted'
|
||||
attempt.status = PhotoVerification.STATUS.submitted
|
||||
attempt.save()
|
||||
|
||||
# Test method 'get_initial_verification' still returns the correct
|
||||
@@ -332,7 +293,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
|
||||
# Populate Record
|
||||
attempt.mark_ready()
|
||||
attempt.status = "submitted"
|
||||
attempt.status = PhotoVerification.STATUS.submitted
|
||||
attempt.photo_id_image_url = "https://example.com/test/image/img.jpg"
|
||||
attempt.face_image_url = "https://example.com/test/face/img.jpg"
|
||||
attempt.photo_id_key = 'there_was_an_attempt'
|
||||
@@ -379,7 +340,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
for _ in range(2):
|
||||
# Make an approved verification
|
||||
attempt = SoftwareSecurePhotoVerification(user=user)
|
||||
attempt.status = 'approved'
|
||||
attempt.status = PhotoVerification.STATUS.approved
|
||||
attempt.expiry_date = datetime.now()
|
||||
attempt.save()
|
||||
|
||||
@@ -400,7 +361,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
for _ in range(2):
|
||||
# Make an approved verification
|
||||
attempt = SoftwareSecurePhotoVerification(user=user)
|
||||
attempt.status = 'approved'
|
||||
attempt.status = PhotoVerification.STATUS.approved
|
||||
attempt.save()
|
||||
|
||||
# Test method 'get_recent_verification' returns None
|
||||
@@ -428,7 +389,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
user = UserFactory.create()
|
||||
verification = SoftwareSecurePhotoVerification(user=user)
|
||||
verification.expiry_date = now() - timedelta(days=FAKE_SETTINGS['DAYS_GOOD_FOR'])
|
||||
verification.status = 'approved'
|
||||
verification.status = PhotoVerification.STATUS.approved
|
||||
verification.save()
|
||||
|
||||
self.assertIsNone(verification.expiry_email_date)
|
||||
@@ -439,7 +400,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
|
||||
self.assertIsNotNone(result.expiry_email_date)
|
||||
|
||||
|
||||
class SSOVerificationTest(TestVerification):
|
||||
class SSOVerificationTest(TestVerificationBase):
|
||||
"""
|
||||
Tests for the SSOVerification model
|
||||
"""
|
||||
@@ -450,7 +411,7 @@ class SSOVerificationTest(TestVerification):
|
||||
self.verification_active_at_datetime(attempt)
|
||||
|
||||
|
||||
class ManualVerificationTest(TestVerification):
|
||||
class ManualVerificationTest(TestVerificationBase):
|
||||
"""
|
||||
Tests for the ManualVerification model
|
||||
"""
|
||||
|
||||
38
lms/djangoapps/verify_student/tests/test_tasks.py
Normal file
38
lms/djangoapps/verify_student/tests/test_tasks.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Lots of patching to stub in our own settings, and HTTP posting
|
||||
import ddt
|
||||
import mock
|
||||
from django.conf import settings
|
||||
from mock import patch
|
||||
|
||||
from common.test.utils import MockS3BotoMixin
|
||||
from verify_student.tests import TestVerificationBase
|
||||
from verify_student.tests.test_models import FAKE_SETTINGS, mock_software_secure_post_unavailable
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
LOGGER_NAME = 'lms.djangoapps.verify_student.tasks'
|
||||
|
||||
|
||||
@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS)
|
||||
@ddt.ddt
|
||||
class TestPhotoVerificationTasks(TestVerificationBase, MockS3BotoMixin, ModuleStoreTestCase):
|
||||
|
||||
@mock.patch('lms.djangoapps.verify_student.tasks.log')
|
||||
def test_logs_for_retry_until_failure(self, mock_log):
|
||||
retry_max_attempts = settings.SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS
|
||||
with mock.patch('lms.djangoapps.verify_student.tasks.requests.post', new=mock_software_secure_post_unavailable):
|
||||
attempt = self.create_and_submit_attempt_for_user()
|
||||
username = attempt.user.username
|
||||
mock_log.error.assert_called_with(
|
||||
'Software Secure submission failed for user %r, setting status to must_retry',
|
||||
username,
|
||||
exc_info=True
|
||||
)
|
||||
for current_attempt in range(retry_max_attempts):
|
||||
mock_log.error.assert_any_call(
|
||||
('Retrying sending request to Software Secure for user: %r, Receipt ID: %r '
|
||||
'attempt#: %s of %s'),
|
||||
username,
|
||||
attempt.receipt_id,
|
||||
current_attempt,
|
||||
settings.SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS,
|
||||
)
|
||||
@@ -3,7 +3,6 @@
|
||||
Tests of verify_student views.
|
||||
"""
|
||||
|
||||
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -45,6 +44,7 @@ from shoppingcart.models import CertificateItem, Order
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from util.testing import UrlResetMixin
|
||||
from verify_student.tests import TestVerificationBase
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
@@ -54,6 +54,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
|
||||
def mock_render_to_response(*args, **kwargs):
|
||||
return render_to_response(*args, **kwargs)
|
||||
|
||||
|
||||
render_mock = Mock(side_effect=mock_render_to_response)
|
||||
|
||||
PAYMENT_DATA_KEYS = {'payment_processor_name', 'payment_page_url', 'payment_form_data'}
|
||||
@@ -64,6 +65,7 @@ class StartView(TestCase):
|
||||
This view is for the first time student is
|
||||
attempting a Photo Verification.
|
||||
"""
|
||||
|
||||
def start_url(self, course_id=""):
|
||||
return "/verify_student/{0}".format(six.moves.urllib.parse.quote(course_id))
|
||||
|
||||
@@ -80,7 +82,7 @@ class StartView(TestCase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin):
|
||||
class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin, TestVerificationBase):
|
||||
"""
|
||||
Tests for the payment and verification flow views.
|
||||
"""
|
||||
@@ -897,7 +899,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin):
|
||||
|
||||
if status in ["submitted", "approved", "expired", "denied", "error"]:
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt = self.submit_attempt(attempt)
|
||||
|
||||
if status in ["approved", "expired"]:
|
||||
attempt.approve()
|
||||
@@ -1112,6 +1114,7 @@ class CheckoutTestMixin(object):
|
||||
compatibility, the effect of using this endpoint is to choose a specific product
|
||||
(i.e. course mode) and trigger immediate checkout.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
""" Create a user and course. """
|
||||
super(CheckoutTestMixin, self).setUp()
|
||||
@@ -1123,12 +1126,12 @@ class CheckoutTestMixin(object):
|
||||
self.client.login(username="test", password="test")
|
||||
|
||||
def _assert_checked_out(
|
||||
self,
|
||||
post_params,
|
||||
patched_create_order,
|
||||
expected_course_key,
|
||||
expected_mode_slug,
|
||||
expected_status_code=200
|
||||
self,
|
||||
post_params,
|
||||
patched_create_order,
|
||||
expected_course_key,
|
||||
expected_mode_slug,
|
||||
expected_status_code=200
|
||||
):
|
||||
"""
|
||||
DRY helper.
|
||||
@@ -1413,7 +1416,7 @@ class TestCreateOrderView(ModuleStoreTestCase):
|
||||
|
||||
@ddt.ddt
|
||||
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
|
||||
class TestSubmitPhotosForVerification(MockS3BotoMixin, TestCase):
|
||||
class TestSubmitPhotosForVerification(MockS3BotoMixin, TestVerificationBase):
|
||||
"""
|
||||
Tests for submitting photos for verification.
|
||||
"""
|
||||
@@ -1571,6 +1574,7 @@ class TestSubmitPhotosForVerification(MockS3BotoMixin, TestCase):
|
||||
# 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):
|
||||
"""Submit photos for verification.
|
||||
|
||||
@@ -1596,7 +1600,8 @@ class TestSubmitPhotosForVerification(MockS3BotoMixin, TestCase):
|
||||
if full_name is not None:
|
||||
params['full_name'] = full_name
|
||||
|
||||
response = self.client.post(url, params)
|
||||
with self.immediate_on_commit():
|
||||
response = self.client.post(url, params)
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
|
||||
return response
|
||||
@@ -1637,10 +1642,11 @@ class TestSubmitPhotosForVerification(MockS3BotoMixin, TestCase):
|
||||
return json.loads(last_request.body)
|
||||
|
||||
|
||||
class TestPhotoVerificationResultsCallback(ModuleStoreTestCase):
|
||||
class TestPhotoVerificationResultsCallback(ModuleStoreTestCase, TestVerificationBase):
|
||||
"""
|
||||
Tests for the results_callback view.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestPhotoVerificationResultsCallback, self).setUp()
|
||||
|
||||
@@ -1750,9 +1756,7 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase):
|
||||
expiry_date = now() + timedelta(
|
||||
days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]
|
||||
)
|
||||
verification = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
verification.mark_ready()
|
||||
verification.submit()
|
||||
verification = self.create_and_submit_attempt_for_user(self.user)
|
||||
verification.approve()
|
||||
verification.expiry_date = now()
|
||||
verification.expiry_email_date = now()
|
||||
@@ -1899,11 +1903,11 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase):
|
||||
self.assertContains(response, 'Result Unknown not understood', status_code=400)
|
||||
|
||||
|
||||
class TestReverifyView(TestCase):
|
||||
class TestReverifyView(TestVerificationBase):
|
||||
"""
|
||||
Tests for the reverification view.
|
||||
Tests for the re-verification view.
|
||||
|
||||
Reverification occurs when a verification attempt is denied or expired,
|
||||
Re-verification occurs when a verification attempt is denied or expired,
|
||||
and the student is given the option to resubmit.
|
||||
"""
|
||||
|
||||
@@ -1918,30 +1922,26 @@ class TestReverifyView(TestCase):
|
||||
|
||||
def test_reverify_view_can_do_initial_verification(self):
|
||||
"""
|
||||
Test that a User can use reverify link for initial verification.
|
||||
Test that a User can use re-verify link for initial verification.
|
||||
"""
|
||||
self._assert_can_reverify()
|
||||
|
||||
def test_reverify_view_can_reverify_denied(self):
|
||||
# User has a denied attempt, so can reverify
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
# User has a denied attempt, so can re-verify
|
||||
attempt = self.create_and_submit_attempt_for_user(self.user)
|
||||
attempt.deny("error")
|
||||
self._assert_can_reverify()
|
||||
|
||||
def test_reverify_view_can_reverify_expired(self):
|
||||
# User has a verification attempt, but it's expired
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt = self.create_and_submit_attempt_for_user(self.user)
|
||||
attempt.approve()
|
||||
|
||||
days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]
|
||||
attempt.created_at = now() - timedelta(days=(days_good_for + 1))
|
||||
attempt.save()
|
||||
|
||||
# Allow the student to reverify
|
||||
# Allow the student to re-verify
|
||||
self._assert_can_reverify()
|
||||
|
||||
def test_reverify_view_can_reverify_pending(self):
|
||||
@@ -1954,21 +1954,17 @@ class TestReverifyView(TestCase):
|
||||
"""
|
||||
|
||||
# User has submitted a verification attempt, but Software Secure has not yet responded
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt = self.create_and_submit_attempt_for_user(self.user)
|
||||
|
||||
# Can re-verify because an attempt has already been submitted.
|
||||
self._assert_can_reverify()
|
||||
|
||||
def test_reverify_view_cannot_reverify_approved(self):
|
||||
# Submitted attempt has been approved
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt = self.create_and_submit_attempt_for_user(self.user)
|
||||
attempt.approve()
|
||||
|
||||
# Cannot reverify because the user is already verified.
|
||||
# Cannot re-verify because the user is already verified.
|
||||
self._assert_cannot_reverify()
|
||||
|
||||
@override_settings(VERIFY_STUDENT={"DAYS_GOOD_FOR": 5, "EXPIRING_SOON_WINDOW": 10})
|
||||
@@ -1979,10 +1975,7 @@ class TestReverifyView(TestCase):
|
||||
and learner can submit photos if verification is set to expire in
|
||||
EXPIRING_SOON_WINDOW(i.e here it is 10 days) or less days.
|
||||
"""
|
||||
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt = self.create_and_submit_attempt_for_user(self.user)
|
||||
attempt.approve()
|
||||
|
||||
# Can re-verify because verification is set to expired soon.
|
||||
@@ -1990,21 +1983,21 @@ class TestReverifyView(TestCase):
|
||||
|
||||
def _get_reverify_page(self):
|
||||
"""
|
||||
Retrieve the reverification page and return the response.
|
||||
Retrieve the re-verification page and return the response.
|
||||
"""
|
||||
url = reverse("verify_student_reverify")
|
||||
return self.client.get(url)
|
||||
|
||||
def _assert_can_reverify(self):
|
||||
"""
|
||||
Check that the reverification flow is rendered.
|
||||
Check that the re-verification flow is rendered.
|
||||
"""
|
||||
response = self._get_reverify_page()
|
||||
self.assertContains(response, "reverify-container")
|
||||
|
||||
def _assert_cannot_reverify(self):
|
||||
"""
|
||||
Check that the user is blocked from reverifying.
|
||||
Check that the user is blocked from re-verifying.
|
||||
"""
|
||||
response = self._get_reverify_page()
|
||||
self.assertContains(response, "reverify-blocked")
|
||||
|
||||
@@ -101,3 +101,18 @@ def most_recent_verification(photo_id_verifications, sso_id_verifications, manua
|
||||
}
|
||||
|
||||
return max(verifications_map, key=lambda k: verifications_map[k]) if verifications_map else None
|
||||
|
||||
|
||||
def auto_verify_for_testing_enabled(override=None):
|
||||
"""
|
||||
If AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING is True, we want to skip posting
|
||||
anything to Software Secure.
|
||||
|
||||
Bypass posting anything to Software Secure if auto verify feature for testing is enabled.
|
||||
We actually don't even create the message because that would require encryption and message
|
||||
signing that rely on settings.VERIFY_STUDENT values that aren't set in dev. So we just
|
||||
pretend like we successfully posted.
|
||||
"""
|
||||
if override is not None:
|
||||
return override
|
||||
return settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING')
|
||||
|
||||
@@ -239,7 +239,7 @@ class PayAndVerifyView(View):
|
||||
|
||||
# Verify that the course exists
|
||||
if course is None:
|
||||
log.warn(u"Could not find course with ID %s.", course_id)
|
||||
log.warning(u"Could not find course with ID %s.", course_id)
|
||||
raise Http404
|
||||
|
||||
# Check whether the user has access to this course
|
||||
@@ -295,7 +295,7 @@ class PayAndVerifyView(View):
|
||||
else:
|
||||
# Otherwise, there has never been a verified/paid mode,
|
||||
# so return a page not found response.
|
||||
log.warn(
|
||||
log.warning(
|
||||
u"No paid/verified course mode found for course '%s' for verification/payment flow request",
|
||||
course_id
|
||||
)
|
||||
@@ -818,12 +818,12 @@ def create_order(request):
|
||||
paid_modes = CourseMode.paid_modes_for_course(course_id)
|
||||
if paid_modes:
|
||||
if len(paid_modes) > 1:
|
||||
log.warn(u"Multiple paid course modes found for course '%s' for create order request", course_id)
|
||||
log.warning(u"Multiple paid course modes found for course '%s' for create order request", course_id)
|
||||
current_mode = paid_modes[0]
|
||||
|
||||
# Make sure this course has a paid mode
|
||||
if not current_mode:
|
||||
log.warn(u"Create order requested for course '%s' without a paid mode.", course_id)
|
||||
log.warning(u"Create order requested for course '%s' without a paid mode.", course_id)
|
||||
return HttpResponseBadRequest(_("This course doesn't support paid certificates"))
|
||||
|
||||
if CourseMode.is_professional_mode(current_mode):
|
||||
@@ -899,7 +899,7 @@ class SubmitPhotosView(View):
|
||||
|
||||
# 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.
|
||||
# a photo ID image if this is a re-verification.
|
||||
face_image, photo_id_image, response = self._decode_image_data(
|
||||
params["face_image"], params.get("photo_id_image")
|
||||
)
|
||||
|
||||
@@ -511,6 +511,13 @@ XQUEUE_INTERFACE = {
|
||||
# Used with Email sending
|
||||
RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5
|
||||
RETRY_ACTIVATION_EMAIL_TIMEOUT = 0.5
|
||||
|
||||
# Software Secure request retry settings
|
||||
# Time in seconds before a retry of the task should be 60 mints.
|
||||
SOFTWARE_SECURE_REQUEST_RETRY_DELAY = 60 * 60
|
||||
# Maximum of 6 retries before giving up.
|
||||
SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS = 6
|
||||
|
||||
PASSWORD_RESET_EMAIL_RATE_LIMIT = {
|
||||
'no_of_emails': 1,
|
||||
'per_seconds': 60
|
||||
@@ -2154,7 +2161,6 @@ CELERY_BROKER_TRANSPORT = 'amqp'
|
||||
CELERY_BROKER_HOSTNAME = 'localhost'
|
||||
CELERY_BROKER_USER = 'celery'
|
||||
CELERY_BROKER_PASSWORD = 'celery'
|
||||
CELERY_TIMEZONE = 'UTC'
|
||||
|
||||
################################ Block Structures ###################################
|
||||
|
||||
@@ -2837,6 +2843,8 @@ POLICY_CHANGE_GRADES_ROUTING_KEY = 'edx.lms.core.default'
|
||||
|
||||
RECALCULATE_GRADES_ROUTING_KEY = 'edx.lms.core.default'
|
||||
|
||||
SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = 'edx.lms.core.default'
|
||||
|
||||
GRADES_DOWNLOAD = {
|
||||
'STORAGE_CLASS': 'django.core.files.storage.FileSystemStorage',
|
||||
'STORAGE_KWARGS': {
|
||||
|
||||
@@ -51,7 +51,7 @@ CONFIG_FILE = get_env_setting('LMS_CFG')
|
||||
with codecs.open(CONFIG_FILE, encoding='utf-8') as f:
|
||||
__config__ = yaml.safe_load(f)
|
||||
|
||||
# ENV_TOKENS and AUTH_TOKENS are included for reverse compatability.
|
||||
# ENV_TOKENS and AUTH_TOKENS are included for reverse compatibility.
|
||||
# Removing them may break plugins that rely on them.
|
||||
ENV_TOKENS = __config__
|
||||
AUTH_TOKENS = __config__
|
||||
@@ -966,6 +966,10 @@ CREDENTIALS_GENERATION_ROUTING_KEY = ENV_TOKENS.get('CREDENTIALS_GENERATION_ROUT
|
||||
|
||||
# Queue to use for award program certificates
|
||||
PROGRAM_CERTIFICATES_ROUTING_KEY = ENV_TOKENS.get('PROGRAM_CERTIFICATES_ROUTING_KEY', DEFAULT_PRIORITY_QUEUE)
|
||||
SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = ENV_TOKENS.get(
|
||||
'SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY',
|
||||
HIGH_PRIORITY_QUEUE
|
||||
)
|
||||
|
||||
API_ACCESS_MANAGER_EMAIL = ENV_TOKENS.get('API_ACCESS_MANAGER_EMAIL')
|
||||
API_ACCESS_FROM_EMAIL = ENV_TOKENS.get('API_ACCESS_FROM_EMAIL')
|
||||
|
||||
@@ -123,3 +123,10 @@ RETIREMENT_SERVICE_WORKER_USERNAME = 'RETIREMENT_SERVICE_USER'
|
||||
RETIRED_USERNAME_PREFIX = 'retired__user_'
|
||||
|
||||
PROCTORING_SETTINGS = {}
|
||||
|
||||
|
||||
# Software Secure request retry settings
|
||||
# Time in seconds before a retry of the task should be 60 mints.
|
||||
SOFTWARE_SECURE_REQUEST_RETRY_DELAY = 60 * 60
|
||||
# Maximum of 6 retries before giving up.
|
||||
SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS = 6
|
||||
|
||||
Reference in New Issue
Block a user