From 66b780d6933286eb94098d20a1063d0c929b3866 Mon Sep 17 00:00:00 2001 From: Zainab Amir Date: Wed, 27 Feb 2019 11:47:51 +0500 Subject: [PATCH] LEARNER-7136 populate expiry date and add mechanism for new verification Method approve() is overridden to accommodate new users that will have their verification approved in the future. The approve() method is called from software secure results callback every time a Software Secure Photo Verification is approved.The method overridden now updates expiry_date and then calls super to perform task it was performing before this change.The expiry_date set is the same as the one sent in verification approval email. In results_callback for sspv the expiry_date and expiry_email_date for the most recent previous verification if exists are also updated to NULL. --- .../commands/populate_expiry_date.py | 104 +++++++++++++++ .../tests/test_populate_expiry_date.py | 124 ++++++++++++++++++ lms/djangoapps/verify_student/models.py | 17 +++ .../verify_student/tests/test_views.py | 49 +++++++ lms/djangoapps/verify_student/views.py | 12 +- 5 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/verify_student/management/commands/populate_expiry_date.py create mode 100644 lms/djangoapps/verify_student/management/commands/tests/test_populate_expiry_date.py diff --git a/lms/djangoapps/verify_student/management/commands/populate_expiry_date.py b/lms/djangoapps/verify_student/management/commands/populate_expiry_date.py new file mode 100644 index 0000000000..adf6db2125 --- /dev/null +++ b/lms/djangoapps/verify_student/management/commands/populate_expiry_date.py @@ -0,0 +1,104 @@ +""" +Django admin command to populate expiry_date for approved verifications in SoftwareSecurePhotoVerification +""" +import logging +import time +from datetime import timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db.models import F +from util.query import use_read_replica_if_available + +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + This command sets the expiry_date for users for which the verification is approved + + The task is performed in batches with maximum number of rows to process given in argument `batch_size` + and a sleep time between each batch given by `sleep_time` + + Default values: + `batch_size` = 1000 rows + `sleep_time` = 10 seconds + + Example usage: + $ ./manage.py lms populate_expiry_date --batch_size=1000 --sleep_time=5 + OR + $ ./manage.py lms populate_expiry_date + """ + help = 'Populate expiry_date for approved verifications' + + def add_arguments(self, parser): + parser.add_argument( + '--batch_size', + action='store', + dest='batch_size', + type=int, + default=1000, + help='Maximum number of database rows to process. ' + 'This helps avoid locking the database while updating large amount of data.') + parser.add_argument( + '--sleep_time', + action='store', + dest='sleep_time', + type=int, + default=10, + help='Sleep time in seconds between update of batches') + + def handle(self, *args, **options): + """ + Handler for the command + + It filters approved Software Secure Photo Verification and then for each distinct user it finds the most + recent approved verification and set its expiry_date + """ + batch_size = options['batch_size'] + sleep_time = options['sleep_time'] + + query = SoftwareSecurePhotoVerification.objects.filter(status='approved').order_by() + sspv = use_read_replica_if_available(query) + + if not sspv.count(): + logger.info("No approved entries found in SoftwareSecurePhotoVerification") + return + + distinct_user_ids = set() + update_verification_ids = [] + update_verification_count = 0 + + for verification in sspv: + if verification.user_id not in distinct_user_ids: + distinct_user_ids.add(verification.user_id) + + recent_verification = self.find_recent_verification(sspv, verification.user_id) + if not recent_verification.expiry_date: + update_verification_ids.append(recent_verification.pk) + update_verification_count += 1 + + if update_verification_count == batch_size: + self.bulk_update(update_verification_ids) + update_verification_count = 0 + update_verification_ids = [] + time.sleep(sleep_time) + + if update_verification_ids: + self.bulk_update(update_verification_ids) + + def bulk_update(self, verification_ids): + """ + It updates the expiry_date for all the verification whose ids lie in verification_ids + """ + recent_verification_qs = SoftwareSecurePhotoVerification.objects.filter(pk__in=verification_ids) + recent_verification_qs.update(expiry_date=F('updated_at') + timedelta( + days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])) + + def find_recent_verification(self, model, user_id): + """ + Returns the most recent approved verification for a user + """ + return model.filter(user_id=user_id).latest('updated_at') diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_populate_expiry_date.py b/lms/djangoapps/verify_student/management/commands/tests/test_populate_expiry_date.py new file mode 100644 index 0000000000..7c4f10b651 --- /dev/null +++ b/lms/djangoapps/verify_student/management/commands/tests/test_populate_expiry_date.py @@ -0,0 +1,124 @@ +""" +Tests for django admin command `populate_expiry_date` in the verify_student module +""" + +from datetime import timedelta + +import boto +from django.conf import settings +from django.core.management import call_command +from django.test import TestCase +from mock import patch +from student.tests.factories import UserFactory +from testfixtures import LogCapture + +from common.test.utils import MockS3Mixin +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.tests.test_models import ( + FAKE_SETTINGS, + mock_software_secure_post +) + +LOGGER_NAME = 'lms.djangoapps.verify_student.management.commands.populate_expiry_date' + + +@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) +@patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post) +class TestPopulateExpiryDate(MockS3Mixin, TestCase): + """ Tests for django admin command `populate_expiry_date` in the verify_student module """ + + def setUp(self): + """ Initial set up for tests """ + super(TestPopulateExpiryDate, self).setUp() + connection = boto.connect_s3() + connection.create_bucket(FAKE_SETTINGS['SOFTWARE_SECURE']['S3_BUCKET']) + + def create_and_submit(self, user): + """ Helper method that lets us create new SoftwareSecurePhotoVerifications """ + attempt = SoftwareSecurePhotoVerification(user=user) + attempt.upload_face_image("Fake Data") + attempt.upload_photo_id_image("More Fake Data") + attempt.mark_ready() + attempt.submit() + return attempt + + def test_expiry_date_already_present(self): + """ + Test that the expiry_date for most recent approved verification is updated only when the + expiry_date is not already present + """ + user = UserFactory.create() + verification = self.create_and_submit(user) + verification.status = 'approved' + verification.expiry_date = verification.updated_at + timedelta(days=10) + verification.save() + + expiry_date = verification.expiry_date + call_command('populate_expiry_date') + + # Check that the expiry_date for approved verification is not changed when it is already present + verification_expiry_date = SoftwareSecurePhotoVerification.objects.get(pk=verification.pk).expiry_date + + self.assertEqual(verification_expiry_date, expiry_date) + + def test_recent_approved_verification(self): + """ + Test that the expiry_date for most recent approved verification is updated + A user can have multiple approved Software Secure Photo Verification over the year + Only the most recent is considered for course verification + """ + user = UserFactory.create() + outdated_verification = self.create_and_submit(user) + outdated_verification.status = 'approved' + outdated_verification.save() + + recent_verification = self.create_and_submit(user) + recent_verification.status = 'approved' + recent_verification.save() + + call_command('populate_expiry_date') + + # Check that expiry_date for only one verification is set + assert len(SoftwareSecurePhotoVerification.objects.filter(expiry_date__isnull=False)) == 1 + + # Check that the expiry_date date set for verification is not for the outdated approved verification + expiry_date = SoftwareSecurePhotoVerification.objects.get(pk=outdated_verification.pk).expiry_date + self.assertIsNone(expiry_date) + + def test_approved_verification_expiry_date(self): + """ + Tests that the command correctly updates expiry_date + Criteria : + Verification for which status is approved and expiry_date is null + """ + # Create verification with status : submitted + user = UserFactory.create() + self.create_and_submit(user) + + # Create verification with status : approved + approved_verification = self.create_and_submit(user) + approved_verification.status = 'approved' + approved_verification.save() + + expected_date = approved_verification.updated_at + timedelta( + days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + + call_command('populate_expiry_date') + + # Check to make sure we have one verification with expiry_date set and one with null + assert len(SoftwareSecurePhotoVerification.objects.filter(expiry_date__isnull=True)) == 1 + assert len(SoftwareSecurePhotoVerification.objects.filter(expiry_date__isnull=False)) == 1 + + # Confirm that expiry_date set for approved verification is correct + approved_verification = SoftwareSecurePhotoVerification.objects.get(pk=approved_verification.pk) + self.assertEqual(approved_verification.expiry_date, expected_date) + + def test_no_approved_verification_found(self): + """ + Test that if no approved verifications are found the management command terminates gracefully + """ + with LogCapture(LOGGER_NAME) as logger: + call_command('populate_expiry_date') + logger.check( + (LOGGER_NAME, 'INFO', "No approved entries found in SoftwareSecurePhotoVerification") + ) diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 425437a666..5ac659ca0a 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -561,6 +561,23 @@ class SoftwareSecurePhotoVerification(PhotoVerification): expiry_date = models.DateTimeField(null=True, blank=True, db_index=True) expiry_email_date = models.DateTimeField(null=True, blank=True, db_index=True) + @status_before_must_be("must_retry", "submitted", "approved", "denied") + def approve(self, user_id=None, service=""): + """ + Approve the verification attempt for user + + Valid attempt statuses when calling this method: + `submitted`, `approved`, `denied` + + After method completes: + status is set to `approved` + expiry_date is set to one year from now + """ + self.expiry_date = datetime.now(pytz.UTC) + timedelta( + days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] + ) + super(SoftwareSecurePhotoVerification, self).approve(user_id, service) + @classmethod def get_initial_verification(cls, user, earliest_allowed_date=None): """Get initial verification for a user with the 'photo_id_key'. diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 0abd7442c3..875f2ed8e6 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -1764,6 +1764,17 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase): """ Test for verification passed. """ + expiry_date = datetime.now(pytz.UTC) + timedelta( + days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] + ) + verification = SoftwareSecurePhotoVerification.objects.create(user=self.user) + verification.mark_ready() + verification.submit() + verification.approve() + verification.expiry_date = datetime.now(pytz.UTC) + verification.expiry_email_date = datetime.now(pytz.UTC) + verification.save() + data = { "EdX-ID": self.receipt_id, "Result": "PASS", @@ -1778,7 +1789,45 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase): HTTP_DATE='testdate' ) attempt = SoftwareSecurePhotoVerification.objects.get(receipt_id=self.receipt_id) + old_verification = SoftwareSecurePhotoVerification.objects.get(pk=verification.pk) self.assertEqual(attempt.status, u'approved') + self.assertEqual(attempt.expiry_date.date(), expiry_date.date()) + self.assertIsNone(old_verification.expiry_date) + self.assertIsNone(old_verification.expiry_email_date) + self.assertEquals(response.content, 'OK!') + self.assertEqual(len(mail.outbox), 1) + + @patch( + 'lms.djangoapps.verify_student.ssencrypt.has_valid_signature', + mock.Mock(side_effect=mocked_has_valid_signature) + ) + @patch('lms.djangoapps.verify_student.views.log.error') + @patch('lms.djangoapps.verify_student.utils.SailthruClient.send') + def test_first_time_verification(self, mock_sailthru_send, mock_log_error): # pylint: disable=unused-argument + """ + Test for verification passed if the learner does not have any previous verification + """ + expiry_date = datetime.now(pytz.UTC) + timedelta( + days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] + ) + + data = { + "EdX-ID": self.receipt_id, + "Result": "PASS", + "Reason": "", + "MessageType": "You have been verified." + } + json_data = json.dumps(data) + response = self.client.post( + reverse('verify_student_results_callback'), data=json_data, + content_type='application/json', + HTTP_AUTHORIZATION='test BBBBBBBBBBBBBBBBBBBB:testing', + HTTP_DATE='testdate' + ) + + attempt = SoftwareSecurePhotoVerification.objects.get(receipt_id=self.receipt_id) + self.assertEqual(attempt.status, u'approved') + self.assertEqual(attempt.expiry_date.date(), expiry_date.date()) self.assertEquals(response.content, 'OK!') self.assertEqual(len(mail.outbox), 1) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index db601878b1..39c8ea601b 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -1158,7 +1158,17 @@ def results_callback(request): 'platform_name': settings.PLATFORM_NAME, } if result == "PASS": - log.debug(u"Approving verification for %s", receipt_id) + # If this verification is not an outdated version then make expiry date of previous approved verification NULL + # Setting expiry date to NULL is important so that it does not get filtered in the management command + # that sends email when verification expires : verify_student/send_verification_expiry_email + if attempt.status != 'approved': + verification = SoftwareSecurePhotoVerification.objects.filter(status='approved', user_id=attempt.user_id) + if verification: + log.info(u'Making expiry date of previous approved verification NULL for {}'.format(attempt.user_id)) + previous_verification = verification.latest('updated_at') + SoftwareSecurePhotoVerification.objects.filter(pk=previous_verification.pk + ).update(expiry_date=None, expiry_email_date=None) + log.debug(u'Approving verification for {}'.format(receipt_id)) attempt.approve() status = u"approved" expiry_date = datetime.date.today() + datetime.timedelta(