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:
@@ -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))
|
||||
@@ -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)
|
||||
15
lms/djangoapps/verify_student/message_types.py
Normal file
15
lms/djangoapps/verify_student/message_types.py
Normal 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
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1 @@
|
||||
{{ platform_name }}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends 'ace_common/edx_ace/common/base_head.html' %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{% blocktrans trimmed %}Your {{ platform_name }} Verification has Expired{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
Reference in New Issue
Block a user