Management Command to Send Verification Expiry Email

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
This commit is contained in:
Zainab Amir
2019-03-07 15:20:25 +05:00
committed by GitHub
8 changed files with 430 additions and 0 deletions

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,38 @@
{% extends 'ace_common/edx_ace/common/base_body.html' %}
{% load i18n %}
{% load static %}
{% block content %}
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<h1>
{% trans "Expired ID Verification" %}
</h1>
<p style="color: rgba(0,0,0,.75);">
{% blocktrans %}
Hello {{full_name}},
Your {{platform_name}} ID verification has expired.
{% endblocktrans %}
</p>
<p style="color: rgba(0,0,0,.75);">
{% 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." %}
</p>
<p style="color: rgba(0,0,0,.75);">
{% blocktrans %}
Resubmit Verification : {{lms_verification_link}}
ID verification FAQ : {{help_center_link}}
{% endblocktrans %}
</p>
<p>
{% blocktrans %}
Thank you,
The {{ platform_name }} Team
{% endblocktrans %}
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -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 %}

View File

@@ -0,0 +1 @@
{{ platform_name }}

View File

@@ -0,0 +1 @@
{% extends 'ace_common/edx_ace/common/base_head.html' %}

View File

@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans trimmed %}Your {{ platform_name }} Verification has Expired{% endblocktrans %}
{% endautoescape %}