diff --git a/cms/envs/common.py b/cms/envs/common.py index 42fe966ebd777108a619b58f5f077357481ddcd1..531721413e995912ea2dc284f18603b90cd3b903 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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' diff --git a/cms/envs/production.py b/cms/envs/production.py index 98fee62bf8e2900e9d83dd41a55a79bcaf966152..0d478ee089bcd2a5083b17beae3eadc9eb8e3275 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -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", {})) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 220927d02bb170ffc81bff0d2e73d09a761ad4cd..c5953682ce7267019dfc0388b0122846f4bbc909 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -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')) diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py b/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py index 1d71839e202514e34b9524e73a805ce7dc86a857..5cfa3667e6d29b1486503f3bc06831f61ea7c5aa 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py @@ -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") - 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 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 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', - u"SSPVerificationRetryConfig is disabled or empty, but --args-from-database was requested." + 'SSPVerificationRetryConfig is disabled or empty, but --args-from-database was requested.' ), ) - # Add a config - config = SSPVerificationRetryConfig.current() - config.arguments = '--verification-ids 1 2 3' - config.enabled = True - config.save() - + 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" - ), - ) diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index cdf9250ae8c7043765b79e747c135b6769df03a9..3f27affb7f9640dcdfb881dce87766adb5dbb623 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -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 diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 3f7b2e28eac3f060b74b8b7fc063ef9a61e69e05..49d41120e8ba397c67dc3e34441231e6217ee9fc 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -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): diff --git a/lms/djangoapps/verify_student/tasks.py b/lms/djangoapps/verify_student/tasks.py index 705158acfa978b101b99eb93c27be7d2700f6576..a6371b971c3a5e84e10d50cc5cd0e9dff278b0de 100644 --- a/lms/djangoapps/verify_student/tasks.py +++ b/lms/djangoapps/verify_student/tasks.py @@ -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() diff --git a/lms/djangoapps/verify_student/tests/__init__.py b/lms/djangoapps/verify_student/tests/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e889ad708b5d15873fe7cc71a14e525cef39ae87 100644 --- a/lms/djangoapps/verify_student/tests/__init__.py +++ b/lms/djangoapps/verify_student/tests/__init__.py @@ -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) diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index fe59f36f237e5044a9a53aeb324649573ee717e2..c008b01386fba6a22c47814692f6d72c9cddea70 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -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 """ diff --git a/lms/djangoapps/verify_student/tests/test_tasks.py b/lms/djangoapps/verify_student/tests/test_tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..dd04a4d23b40193c5a836a24f08b4fc228c10a23 --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_tasks.py @@ -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, + ) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 94db7dcc40ba1922011bf63aaa76f81bac1095c8..6e4b3ce3a0f174d823d65c9f8f97f86a9f636d57 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -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") diff --git a/lms/djangoapps/verify_student/utils.py b/lms/djangoapps/verify_student/utils.py index cbe4441c03000cd6a0c58bb7e096eed46c6e88af..111ae1bdb660e3b925c49443ad8dac944c89d725 100644 --- a/lms/djangoapps/verify_student/utils.py +++ b/lms/djangoapps/verify_student/utils.py @@ -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') diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index d6290eb06205bf3aa7e49efef13be10423d0e057..4441110ce5c4a588c3ca1b8b84d07cb68dd3d7db 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -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") ) diff --git a/lms/envs/common.py b/lms/envs/common.py index de3a2ca75447f359850181d0af96c02e27f82af2..694a8c7d793e2b5b7393d6fc0f3d4a2704397ca6 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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': { diff --git a/lms/envs/production.py b/lms/envs/production.py index c8a1164db2fa41feee97fc80f1f75a6bcd1acd03..7ff5b138392d1771c645a4d7abe8ab76f66de87f 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -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') diff --git a/openedx/tests/settings.py b/openedx/tests/settings.py index a80a6572650043ed3fac627964e2c234e62a3e90..7eb499b348e86654b71f6f36ffb797c9aa5c7020 100644 --- a/openedx/tests/settings.py +++ b/openedx/tests/settings.py @@ -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