From 2dbae49869190a87ae0ecd278270529644fe5a39 Mon Sep 17 00:00:00 2001 From: Zainab Amir Date: Fri, 18 Jan 2019 17:29:41 +0500 Subject: [PATCH] LEARNER-6946 management command to send verification expiry email Template for email added (email/verification_expiry_email) The management command filters the SoftwareSecurePhotoVerification model on the basis of following criteria : -- the verification is approved -- the start_date < expiry_date < today or specified days have passed to resend email After the basic filtering batches are created to send email. For each verification in a batch email is sent and email_expiry_date is set to 15 days from today (default days are 15, it can be changed too) Between each batch there is a delay of sleep_time seconds --- .../send_verification_expiry_email.py | 170 ++++++++++++++++ .../test_send_verification_expiry_email.py | 181 ++++++++++++++++++ .../verify_student/message_types.py | 15 ++ .../verificationexpiry/email/body.html | 38 ++++ .../edx_ace/verificationexpiry/email/body.txt | 20 ++ .../verificationexpiry/email/from_name.txt | 1 + .../verificationexpiry/email/head.html | 1 + .../verificationexpiry/email/subject.txt | 4 + 8 files changed, 430 insertions(+) create mode 100644 lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py create mode 100644 lms/djangoapps/verify_student/management/commands/tests/test_send_verification_expiry_email.py create mode 100644 lms/djangoapps/verify_student/message_types.py create mode 100644 lms/templates/verify_student/edx_ace/verificationexpiry/email/body.html create mode 100644 lms/templates/verify_student/edx_ace/verificationexpiry/email/body.txt create mode 100644 lms/templates/verify_student/edx_ace/verificationexpiry/email/from_name.txt create mode 100644 lms/templates/verify_student/edx_ace/verificationexpiry/email/head.html create mode 100644 lms/templates/verify_student/edx_ace/verificationexpiry/email/subject.txt diff --git a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py new file mode 100644 index 0000000000..d304420477 --- /dev/null +++ b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py @@ -0,0 +1,170 @@ +""" +Django admin command to send verification expiry email to learners +""" +import logging +import time +from datetime import datetime, timedelta + +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand +from django.db.models import Q +from django.urls import reverse +from edx_ace import ace +from edx_ace.recipient import Recipient +from pytz import UTC +from util.query import use_read_replica_if_available +from verify_student.message_types import VerificationExpiry + +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from openedx.core.djangoapps.ace_common.template_context import get_base_template_context +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.user_api.preferences.api import get_user_preference +from openedx.core.lib.celery.task_utils import emulate_http_request + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + This command sends email to learners for which the Software Secure Photo Verification has expired + + The expiry email is sent when the date represented by SoftwareSecurePhotoVerification's field `expiry_date` + lies within the date range provided by command arguments. If the email is already sent indicated by field + `expiry_email_date` then filter if the specified number of days given as command argument `--resend_days` have + passed + + The range to filter expired verification is selected based on --days-range. This represents the number of days + before now and gives us start_date of the range + Range: start_date to today + + The task is performed in batches with maximum number of users to send email given in `batch_size` and the + delay between batches is indicated by `sleep_time`.For each batch a celery task is initiated that sends the email + + Example usage: + $ ./manage.py lms send_verification_expiry_email --resend-days=30 --batch-size=2000 --sleep-time=5 + OR + $ ./manage.py lms send_verification_expiry_email + + To run the command without sending emails: + $ ./manage.py lms send_verification_expiry_email --dry-run + """ + help = 'Send email to users for which Software Secure Photo Verification has expired' + + def add_arguments(self, parser): + parser.add_argument( + '-d', '--resend-days', + type=int, + default=15, + help='Desired days after which the email will be resent to learners with expired verification' + ) + parser.add_argument( + '--batch-size', + type=int, + default=1000, + help='Maximum number of users to send email in one celery task') + parser.add_argument( + '--sleep-time', + type=int, + default=10, + help='Sleep time in seconds between update of batches') + parser.add_argument( + '--days-range', + type=int, + default=1, + help="The number of days before now to check expired verification") + parser.add_argument( + '--dry-run', + action='store_true', + help='Gives the number of user for which the email will be sent in each batch') + + def handle(self, *args, **options): + """ + Handler for the command + + It creates batches of expired Software Secure Photo Verification and sends it to send_verification_expiry_email + that used edx_ace to send email to these learners + """ + resend_days = options['resend_days'] + batch_size = options['batch_size'] + sleep_time = options['sleep_time'] + days = options['days_range'] + dry_run = options['dry_run'] + + # If email was sent and user did not re-verify then this date will be used as the criteria for resending email + date_resend_days_ago = datetime.now(UTC) - timedelta(days=resend_days) + + start_date = datetime.now(UTC) - timedelta(days=days) + + # Adding an order_by() clause will override the class meta ordering as we don't need ordering here + query = SoftwareSecurePhotoVerification.objects.filter(Q(status='approved') & + (Q(expiry_date__date__gte=start_date.date(), + expiry_date__date__lt=datetime.now(UTC).date()) | + Q(expiry_email_date__lt=date_resend_days_ago.date()) + )).order_by() + + sspv = use_read_replica_if_available(query) + + total_verification = sspv.count() + if not total_verification: + logger.info(u"No approved expired entries found in SoftwareSecurePhotoVerification for the " + u"date range {} - {}".format(start_date.date(), datetime.now(UTC).date())) + return + + logger.info(u"For the date range {} - {}, total Software Secure Photo verification filtered are {}" + .format(start_date.date(), datetime.now(UTC).date(), total_verification)) + + batch_verifications = [] + + for verification in sspv: + if not verification.expiry_email_date or verification.expiry_email_date < date_resend_days_ago: + batch_verifications.append(verification) + + if len(batch_verifications) == batch_size: + send_verification_expiry_email(batch_verifications, dry_run) + time.sleep(sleep_time) + batch_verifications = [] + + # If selected verification in batch are less than batch_size + if batch_verifications: + send_verification_expiry_email(batch_verifications, dry_run) + + +def send_verification_expiry_email(batch_verifications, dry_run=False): + """ + Spins a task to send verification expiry email to the learners in the batch using edx_ace + If the email is successfully sent change the expiry_email_date to reflect when the + email was sent + """ + if dry_run: + logger.info( + u"This was a dry run, no email was sent. For the actual run email would have been sent " + u"to {} learner(s)".format(len(batch_verifications)) + ) + return + + site = Site.objects.get(name=settings.SITE_NAME) + message_context = get_base_template_context(site) + message_context.update({ + 'platform_name': settings.PLATFORM_NAME, + 'lms_verification_link': '{}{}'.format(settings.LMS_ROOT_URL, reverse("verify_student_reverify")), + 'help_center_link': settings.ID_VERIFICATION_SUPPORT_LINK + }) + + expiry_email = VerificationExpiry(context=message_context) + users = User.objects.filter(pk__in=[verification.user_id for verification in batch_verifications]) + + for verification in batch_verifications: + user = users.get(pk=verification.user_id) + with emulate_http_request(site=site, user=user): + msg = expiry_email.personalize( + recipient=Recipient(user.username, user.email), + language=get_user_preference(user, LANGUAGE_KEY), + user_context={ + 'full_name': user.profile.name, + } + ) + ace.send(msg) + verification_qs = SoftwareSecurePhotoVerification.objects.filter(pk=verification.pk) + verification_qs.update(expiry_email_date=datetime.now(UTC)) diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_send_verification_expiry_email.py b/lms/djangoapps/verify_student/management/commands/tests/test_send_verification_expiry_email.py new file mode 100644 index 0000000000..35205521ba --- /dev/null +++ b/lms/djangoapps/verify_student/management/commands/tests/test_send_verification_expiry_email.py @@ -0,0 +1,181 @@ +""" +Tests for django admin command `send_verification_expiry_email` in the verify_student module +""" + +from datetime import datetime, timedelta + +import boto +from django.conf import settings +from django.contrib.sites.models import Site +from django.core import mail +from django.core.management import call_command +from django.test import TestCase +from mock import patch +from pytz import UTC +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.send_verification_expiry_email' + + +@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) +@patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post) +class TestSendVerificationExpiryEmail(MockS3Mixin, TestCase): + """ Tests for django admin command `send_verification_expiry_email` in the verify_student module """ + + def setUp(self): + """ Initial set up for tests """ + super(TestSendVerificationExpiryEmail, self).setUp() + connection = boto.connect_s3() + connection.create_bucket(FAKE_SETTINGS['SOFTWARE_SECURE']['S3_BUCKET']) + Site.objects.create(domain='edx.org', name='edx.org') + + 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_range(self): + """ + Test that the verifications are filtered on the given range. Email is not sent for any verification with + expiry date out of range + """ + user = UserFactory.create() + verification_in_range = self.create_and_submit(user) + verification_in_range.status = 'approved' + verification_in_range.expiry_date = datetime.now(UTC) - timedelta(days=1) + verification_in_range.save() + + user = UserFactory.create() + verification = self.create_and_submit(user) + verification.status = 'approved' + verification.expiry_date = datetime.now(UTC) - timedelta(days=5) + verification.save() + + call_command('send_verification_expiry_email', '--days-range=2') + + # Check that only one email is sent + self.assertEqual(len(mail.outbox), 1) + + # Verify that the email is not sent to the out of range verification + expiry_email_date = SoftwareSecurePhotoVerification.objects.get(pk=verification.pk).expiry_email_date + self.assertIsNone(expiry_email_date) + + def test_expiry_email_date_range(self): + """ + Test that the verifications are filtered if the expiry_email_date has reached the time specified for + resending email + """ + user = UserFactory.create() + verification_in_range = self.create_and_submit(user) + verification_in_range.status = 'approved' + verification_in_range.expiry_date = datetime.now(UTC) - timedelta(days=30) + verification_in_range.expiry_email_date = datetime.now(UTC) - timedelta(days=3) + verification_in_range.save() + + command_args = '--days-range={} --resend-days={}' # pylint: disable=unicode-format-string + call_command('send_verification_expiry_email', *command_args.format(2, 2).split(' ')) + + # Check that email is sent even if the verification is not in expiry_date range but matches the criteria + # to resend email + self.assertEqual(len(mail.outbox), 1) + + def test_most_recent_verification(self): + """ + Test that the SoftwareSecurePhotoVerification object is not filtered if it is outdated. A verification is + outdated if it's expiry_date and expiry_email_date is set NULL + """ + # For outdated verification the expiry_date and expiry_email_date is set NULL verify_student/views.py:1164 + user = UserFactory.create() + outdated_verification = self.create_and_submit(user) + outdated_verification.status = 'approved' + outdated_verification.save() + + # Check that the expiry_email_date is not set for the outdated verification + expiry_email_date = SoftwareSecurePhotoVerification.objects.get(pk=outdated_verification.pk).expiry_email_date + self.assertIsNone(expiry_email_date) + + def test_send_verification_expiry_email(self): + """ + Test that checks for valid criteria the email is sent and expiry_email_date is set + """ + user = UserFactory.create() + verification = self.create_and_submit(user) + verification.status = 'approved' + verification.expiry_date = datetime.now(UTC) - timedelta(days=1) + verification.save() + + call_command('send_verification_expiry_email') + + expected_date = datetime.now(UTC) + attempt = SoftwareSecurePhotoVerification.objects.get(user_id=verification.user_id) + self.assertEquals(attempt.expiry_email_date.date(), expected_date.date()) + self.assertEqual(len(mail.outbox), 1) + + def test_email_already_sent(self): + """ + Test that if email is already sent as indicated by expiry_email_date then don't send again if it has been less + than resend_days + """ + user = UserFactory.create() + verification = self.create_and_submit(user) + verification.status = 'approved' + verification.expiry_date = datetime.now(UTC) - timedelta(days=1) + verification.expiry_email_date = datetime.now() + verification.save() + + call_command('send_verification_expiry_email') + + self.assertEqual(len(mail.outbox), 0) + + def test_no_verification_found(self): + """ + Test that if no approved and expired verifications are found the management command terminates gracefully + """ + start_date = datetime.now(UTC) - timedelta(days=1) # using default days + with LogCapture(LOGGER_NAME) as logger: + call_command('send_verification_expiry_email') + logger.check( + (LOGGER_NAME, + 'INFO', u"No approved expired entries found in SoftwareSecurePhotoVerification for the " + u"date range {} - {}".format(start_date.date(), datetime.now(UTC).date())) + ) + + def test_dry_run_flag(self): + """ + Test that the dry run flags sends no email and only logs the the number of email sent in each batch + """ + user = UserFactory.create() + verification = self.create_and_submit(user) + verification.status = 'approved' + verification.expiry_date = datetime.now(UTC) - timedelta(days=1) + verification.save() + + start_date = datetime.now(UTC) - timedelta(days=1) # using default days + count = 1 + + with LogCapture(LOGGER_NAME) as logger: + call_command('send_verification_expiry_email', '--dry-run') + logger.check( + (LOGGER_NAME, + 'INFO', + u"For the date range {} - {}, total Software Secure Photo verification filtered are {}" + .format(start_date.date(), datetime.now(UTC).date(), count) + ), + (LOGGER_NAME, + 'INFO', + u"This was a dry run, no email was sent. For the actual run email would have been sent " + u"to {} learner(s)".format(count) + )) + self.assertEqual(len(mail.outbox), 0) diff --git a/lms/djangoapps/verify_student/message_types.py b/lms/djangoapps/verify_student/message_types.py new file mode 100644 index 0000000000..e9603803c6 --- /dev/null +++ b/lms/djangoapps/verify_student/message_types.py @@ -0,0 +1,15 @@ +""" +ACE message types for the verify_student module. +""" + +from openedx.core.djangoapps.ace_common.message import BaseMessageType + + +class VerificationExpiry(BaseMessageType): + APP_LABEL = 'verify_student' + Name = 'verificationexpiry' + + def __init__(self, *args, **kwargs): + super(VerificationExpiry, self).__init__(*args, **kwargs) + + self.options['transactional'] = True # pylint: disable=unsupported-assignment-operation diff --git a/lms/templates/verify_student/edx_ace/verificationexpiry/email/body.html b/lms/templates/verify_student/edx_ace/verificationexpiry/email/body.html new file mode 100644 index 0000000000..38ea29bbff --- /dev/null +++ b/lms/templates/verify_student/edx_ace/verificationexpiry/email/body.html @@ -0,0 +1,38 @@ +{% extends 'ace_common/edx_ace/common/base_body.html' %} + +{% load i18n %} +{% load static %} +{% block content %} + + + + +
+

+ {% trans "Expired ID Verification" %} +

+

+ {% blocktrans %} + Hello {{full_name}}, + Your {{platform_name}} ID verification has expired. + {% endblocktrans %} +

+

+ {% trans "You must have a valid ID verification to take proctored exams and qualify for certificates."%} + {% trans "Follow the link below to submit your photos and renew your ID verification." %} + {% trans "You can also do this from your dashboard." %} +

+

+ {% blocktrans %} + Resubmit Verification : {{lms_verification_link}} + ID verification FAQ : {{help_center_link}} + {% endblocktrans %} +

+

+ {% blocktrans %} + Thank you, + The {{ platform_name }} Team + {% endblocktrans %} +

+
+{% endblock %} diff --git a/lms/templates/verify_student/edx_ace/verificationexpiry/email/body.txt b/lms/templates/verify_student/edx_ace/verificationexpiry/email/body.txt new file mode 100644 index 0000000000..f167af85f1 --- /dev/null +++ b/lms/templates/verify_student/edx_ace/verificationexpiry/email/body.txt @@ -0,0 +1,20 @@ +{% load i18n %}{% autoescape off %} +{% blocktrans %} +Hello {{full_name}}, +Your {{platform_name}} ID verification has expired. +{% endblocktrans %} + +{% trans "You must have a valid ID verification to take proctored exams and qualify for certificates."%} +{% trans "Follow the link below to submit your photos and renew your ID verification." %} +{% trans "You can also do this from your dashboard." %} + +{% blocktrans %} +Resubmit Verification : {{lms_verification_link}} +ID verification FAQ : {{help_center_link}} +{% endblocktrans %} + +{% blocktrans %} +Thank you, +The {{ platform_name }} Team +{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/verify_student/edx_ace/verificationexpiry/email/from_name.txt b/lms/templates/verify_student/edx_ace/verificationexpiry/email/from_name.txt new file mode 100644 index 0000000000..dcbc23c004 --- /dev/null +++ b/lms/templates/verify_student/edx_ace/verificationexpiry/email/from_name.txt @@ -0,0 +1 @@ +{{ platform_name }} diff --git a/lms/templates/verify_student/edx_ace/verificationexpiry/email/head.html b/lms/templates/verify_student/edx_ace/verificationexpiry/email/head.html new file mode 100644 index 0000000000..366ada7ad9 --- /dev/null +++ b/lms/templates/verify_student/edx_ace/verificationexpiry/email/head.html @@ -0,0 +1 @@ +{% extends 'ace_common/edx_ace/common/base_head.html' %} diff --git a/lms/templates/verify_student/edx_ace/verificationexpiry/email/subject.txt b/lms/templates/verify_student/edx_ace/verificationexpiry/email/subject.txt new file mode 100644 index 0000000000..19da0fc94c --- /dev/null +++ b/lms/templates/verify_student/edx_ace/verificationexpiry/email/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans trimmed %}Your {{ platform_name }} Verification has Expired{% endblocktrans %} +{% endautoescape %}