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..0d05d5fc6c 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,11 @@ 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) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index db601878b1..0d2a4f2f2b 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -1158,7 +1158,16 @@ 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': + log.info(u'Making expiry date of previous approved verification NULL for {}'.format(attempt.user_id)) + verification = SoftwareSecurePhotoVerification.objects.filter(status='approved', + user_id=attempt.user_id).latest('updated_at') + SoftwareSecurePhotoVerification.objects.filter(pk=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(