feat: reminder emails (#30159)

send reminder emails to those users who saved course or program but not enroll within 15 days.
VAN-887
This commit is contained in:
Mubbshar Anwar
2022-04-13 12:27:50 +05:00
committed by GitHub
parent 10e4634a7f
commit 0663795781
14 changed files with 442 additions and 18 deletions

View File

@@ -10,7 +10,7 @@ class SavedCourseAdmin(admin.ModelAdmin):
Admin for the Saved Course table.
"""
list_display = ['email', 'course_id']
list_display = ['email', 'course_id', 'email_sent_count', 'reminder_email_sent']
search_fields = ['email', 'course_id']
@@ -20,7 +20,7 @@ class SavedProgramAdmin(admin.ModelAdmin):
Admin for the Saved Program table.
"""
list_display = ['email', 'program_uuid']
list_display = ['email', 'program_uuid', 'email_sent_count', 'reminder_email_sent']
search_fields = ['email', 'program_uuid']

View File

@@ -24,6 +24,7 @@ log = logging.getLogger(__name__)
POST_EMAIL_KEY = 'openedx.core.djangoapps.util.ratelimit.request_data_email'
REAL_IP_KEY = 'openedx.core.djangoapps.util.ratelimit.real_ip'
USER_SEND_SAVE_FOR_LATER_EMAIL = 'user.send.save.for.later.email'
class CourseSaveForLaterApiView(APIView):
@@ -61,6 +62,14 @@ class CourseSaveForLaterApiView(APIView):
data = request.data
course_id = data.get('course_id')
email = data.get('email')
org_img_url = data.get('org_img_url')
marketing_url = data.get('marketing_url')
weeks_to_complete = data.get('weeks_to_complete', 0)
min_effort = data.get('min_effort', 0)
max_effort = data.get('max_effort', 0)
user_id = request.user.id
pref_lang = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME, 'en')
send_to_self = bool(not request.user.is_anonymous and request.user.email == email)
if getattr(request, 'limited', False):
return Response({'error_code': 'rate-limited'}, status=403)
@@ -80,12 +89,28 @@ class CourseSaveForLaterApiView(APIView):
user_id=user.id,
email=email,
course_id=course_id,
org_img_url=org_img_url,
marketing_url=marketing_url,
weeks_to_complete=weeks_to_complete,
min_effort=min_effort,
max_effort=max_effort,
reminder_email_sent=False,
)
course_data = {
'course': course,
'send_to_self': send_to_self,
'user_id': user_id,
'pref-lang': pref_lang,
'org_img_url': org_img_url,
'marketing_url': marketing_url,
'weeks_to_complete': weeks_to_complete,
'min_effort': min_effort,
'max_effort': max_effort,
'type': 'course',
'reminder': False,
'braze_event': USER_SEND_SAVE_FOR_LATER_EMAIL,
}
if send_email(request, email, course_data):
if send_email(email, course_data):
return Response({'result': 'success'}, status=200)
else:
return Response({'error_code': 'email-not-send'}, status=400)
@@ -120,6 +145,9 @@ class ProgramSaveForLaterApiView(APIView):
data = request.data
program_uuid = data.get('program_uuid')
email = data.get('email')
user_id = request.user.id
pref_lang = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME, 'en')
send_to_self = bool(not request.user.is_anonymous and request.user.email == email)
if getattr(request, 'limited', False):
return Response({'error_code': 'rate-limited'}, status=403)
@@ -139,9 +167,14 @@ class ProgramSaveForLaterApiView(APIView):
if program:
program_data = {
'program': program,
'send_to_self': send_to_self,
'user_id': user_id,
'pref-lang': pref_lang,
'type': 'program',
'reminder': False,
'braze_event': USER_SEND_SAVE_FOR_LATER_EMAIL,
}
if send_email(request, email, program_data):
if send_email(email, program_data):
return Response({'result': 'success'}, status=200)
else:
return Response({'error_code': 'email-not-send'}, status=400)

View File

@@ -12,7 +12,8 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
log = logging.getLogger(__name__)
USER_SENT_EMAIL_SAVE_FOR_LATER = 'edx.bi.user.saveforlater.email.sent'
USER_SAVE_FOR_LATER_EMAIL_SENT = 'edx.bi.user.saveforlater.email.sent'
USER_SAVE_FOR_LATER_REMINDER_EMAIL_SENT = 'edx.bi.user.saveforlater.reminder.email.sent'
def _get_program_pacing(course_runs):
@@ -25,30 +26,28 @@ def _get_program_pacing(course_runs):
return 'Self-paced' if pacing == 'self_paced' else 'Instructor-led'
def _get_event_properties(request, data):
def _get_event_properties(data):
"""
set event properties for course and program which are required in braze email template
"""
lms_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL)
event_properties = {
'time': datetime.now().isoformat(),
'name': 'user.send.save.for.later.email',
'name': data.get('braze_event'),
}
if data.get('type') == 'course':
course = data.get('course')
data = request.data
org_img_url = data.get('org_img_url')
marketing_url = data.get('marketing_url')
event_properties.update({
'properties': {
'course_image_url': '{base_url}{image_path}'.format(
base_url=lms_url, image_path=course.course_image_url
),
'partner_image_url': org_img_url,
'partner_image_url': data.get('org_img_url'),
'enroll_course_url': '{base_url}/register?course_id={course_id}&enrollment_action=enroll&email_opt_in='
'false&save_for_later=true'.format(base_url=lms_url, course_id=course.id),
'view_course_url': marketing_url + '?save_for_later=true' if marketing_url else '#',
'view_course_url': data.get('marketing_url') + '?save_for_later=true' if data.get(
'marketing_url') else '#',
'display_name': course.display_name,
'short_description': course.short_description,
'weeks_to_complete': data.get('weeks_to_complete'),
@@ -84,11 +83,11 @@ def _get_event_properties(request, data):
return event_properties
def send_email(request, email, data):
def send_email(email, data):
"""
Send email through Braze
"""
event_properties = _get_event_properties(request, data)
event_properties = _get_event_properties(data)
braze_client = BrazeClient(
api_key=settings.EDX_BRAZE_API_KEY,
api_url=settings.EDX_BRAZE_API_SERVER,
@@ -109,16 +108,16 @@ def send_email(request, email, data):
event_properties.update({'user_alias': user_alias})
attributes = [{
'user_alias': user_alias,
'pref-lang': request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME, 'en')
'pref-lang': data.get('pref-lang', 'en')
}]
braze_client.track_user(events=[event_properties], attributes=attributes)
event_data = {
'user_id': request.user.id,
'user_id': data.get('user_id'),
'category': 'save-for-later',
'type': event_properties.get('type'),
'send_to_self': bool(not request.user.is_anonymous and request.user.email == email),
'send_to_self': data.get('send_to_self'),
}
if data.get('type') == 'program':
program = data.get('program')
@@ -128,7 +127,7 @@ def send_email(request, email, data):
event_data.update({'course_key': str(course.id)})
tracker.emit(
USER_SENT_EMAIL_SAVE_FOR_LATER,
USER_SAVE_FOR_LATER_REMINDER_EMAIL_SENT if data.get('reminder') else USER_SAVE_FOR_LATER_EMAIL_SENT,
event_data
)
except Exception: # pylint: disable=broad-except

View File

@@ -0,0 +1,92 @@
"""
Management command to send reminder emails.
"""
import logging
from textwrap import dedent
from datetime import datetime, timedelta
from django.conf import settings
from django.core.management import BaseCommand
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from lms.djangoapps.save_for_later.helper import send_email
from lms.djangoapps.save_for_later.models import SavedCourse
from common.djangoapps.student.models import CourseEnrollment
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from common.djangoapps.util.query import use_read_replica_if_available
logger = logging.getLogger(__name__)
USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL = 'user.send.save.for.later.reminder.email'
class Command(BaseCommand):
"""
Command to send reminder emails to those users who
saved course by email but not register within 15 days.
Examples:
./manage.py lms send_course_reminder_emails --batch-size=100
"""
help = dedent(__doc__)
def add_arguments(self, parser):
parser.add_argument(
'--batch-size',
type=int,
default=1000,
help='Maximum number of users to send reminder email in one chunk')
def handle(self, *args, **options):
"""
Handle the send save for later reminder emails.
"""
reminder_email_threshold_date = datetime.now() - timedelta(
days=settings.SAVE_FOR_LATER_REMINDER_EMAIL_THRESHOLD)
saved_courses_ids = SavedCourse.objects.filter(
reminder_email_sent=False, modified__lt=reminder_email_threshold_date
).values_list('id', flat=True)
total = saved_courses_ids.count()
batch_size = max(1, options.get('batch_size'))
num_batches = ((total - 1) / batch_size + 1) if total > 0 else 0
for batch_num in range(int(num_batches)):
reminder_email_sent_ids = []
start = batch_num * batch_size
end = min(start + batch_size, total) - 1
saved_courses_batch_ids = list(saved_courses_ids)[start:end + 1]
query = SavedCourse.objects.filter(id__in=saved_courses_batch_ids).order_by('-modified')
saved_courses = use_read_replica_if_available(query)
for saved_course in saved_courses:
user = User.objects.filter(email=saved_course.email).first()
course_overview = CourseOverview.get_from_id(saved_course.course_id)
course_data = {
'course': course_overview,
'send_to_self': None,
'user_id': saved_course.user_id,
'org_img_url': saved_course.org_img_url,
'marketing_url': saved_course.marketing_url,
'weeks_to_complete': saved_course.weeks_to_complete,
'min_effort': saved_course.min_effort,
'max_effort': saved_course.max_effort,
'type': 'course',
'reminder': True,
'braze_event': USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL,
}
if user:
enrollment = CourseEnrollment.get_enrollment(user, saved_course.course_id)
if enrollment:
continue
email_sent = send_email(saved_course.email, course_data)
if email_sent:
reminder_email_sent_ids.append(saved_course.id)
else:
logging.info("Unable to send reminder email to {user} for {course} course"
.format(user=str(saved_course.email), course=str(saved_course.course_id)))
SavedCourse.objects.filter(id__in=reminder_email_sent_ids).update(reminder_email_sent=True)

View File

@@ -0,0 +1,85 @@
"""
Management command to send program reminder emails.
"""
import logging
from textwrap import dedent
from datetime import datetime, timedelta
from django.conf import settings
from django.core.management import BaseCommand
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from lms.djangoapps.save_for_later.helper import send_email
from lms.djangoapps.save_for_later.models import SavedProgram
from lms.djangoapps.program_enrollments.api import get_program_enrollment
from openedx.core.djangoapps.catalog.utils import get_programs
from common.djangoapps.util.query import use_read_replica_if_available
LOGGER = logging.getLogger(__name__)
USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL = 'user.send.save.for.later.reminder.email'
class Command(BaseCommand):
"""
Command to send reminder emails to those users who saved
program by email but not enroll program within 15 days.
Examples:
./manage.py lms send_program_reminder_emails --batch-size=100
"""
help = dedent(__doc__)
def add_arguments(self, parser):
parser.add_argument(
'--batch-size',
type=int,
default=1000,
help='Maximum number of users to send reminder email in one chunk')
def handle(self, *args, **options):
"""
Handle the send save for later reminder emails.
"""
reminder_email_threshold_date = datetime.now() - timedelta(
days=settings.SAVE_FOR_LATER_REMINDER_EMAIL_THRESHOLD)
saved_program_ids = SavedProgram.objects.filter(
reminder_email_sent=False, modified__lt=reminder_email_threshold_date
).values_list('id', flat=True)
total = saved_program_ids.count()
batch_size = max(1, options.get('batch_size'))
num_batches = ((total - 1) / batch_size + 1) if total > 0 else 0
for batch_num in range(int(num_batches)):
reminder_email_sent_ids = []
start = batch_num * batch_size
end = min(start + batch_size, total) - 1
saved_program_batch_ids = list(saved_program_ids)[start:end + 1]
query = SavedProgram.objects.filter(id__in=saved_program_batch_ids).order_by('-modified')
saved_programs = use_read_replica_if_available(query)
for saved_program in saved_programs:
user = User.objects.filter(email=saved_program.email).first()
program = get_programs(uuid=saved_program.program_uuid)
if program:
program_data = {
'program': program,
'send_to_self': None,
'user_id': saved_program.user_id,
'type': 'program',
'reminder': True,
'braze_event': USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL,
}
if user and get_program_enrollment(program_uuid=saved_program.uuid, user=user):
continue
email_sent = send_email(saved_program.email, program_data)
if email_sent:
reminder_email_sent_ids.append(saved_program.id)
else:
logging.info("Unable to send reminder email to {user} for {program} program"
.format(user=str(saved_program.email), program=str(saved_program.program_uuid)))
SavedProgram.objects.filter(id__in=reminder_email_sent_ids).update(reminder_email_sent=True)

View File

@@ -0,0 +1,39 @@
""" Test the test_send_course_reminder_emails command line script."""
from unittest.mock import patch
import ddt
from django.core.management import call_command
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from openedx.core.djangolib.testing.utils import skip_unless_lms
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.save_for_later.tests.factories import SavedCourseFactory
from lms.djangoapps.save_for_later.models import SavedCourse
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
@ddt.ddt
@skip_unless_lms
class SavedCourseReminderEmailsTest(SharedModuleStoreTestCase):
"""
Test the test_send_course_reminder_emails management command
"""
def setUp(self):
super().setUp()
self.course_id = 'course-v1:edX+DemoX+Demo_Course'
self.user = UserFactory(email='email@test.com', username='jdoe')
self.saved_course = SavedCourseFactory.create(course_id=self.course_id, user_id=self.user.id)
self.saved_course_1 = SavedCourseFactory.create(course_id=self.course_id)
CourseOverviewFactory.create(id=self.saved_course.course_id)
CourseOverviewFactory.create(id=self.saved_course_1.course_id)
def test_send_reminder_emails(self):
with patch('lms.djangoapps.save_for_later.helper.BrazeClient') as mock_task:
call_command('send_course_reminder_emails', '--batch-size=1')
mock_task.assert_called()
saved_course = SavedCourse.objects.filter(course_id=self.course_id).first()
assert saved_course.reminder_email_sent is True
assert saved_course.email_sent_count > 0

View File

@@ -0,0 +1,38 @@
""" Test the test_send_program_reminder_emails command line script."""
from unittest.mock import patch
import ddt
from django.core.management import call_command
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from openedx.core.djangolib.testing.utils import skip_unless_lms
from lms.djangoapps.save_for_later.tests.factories import SavedPogramFactory
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
from lms.djangoapps.save_for_later.models import SavedProgram
@ddt.ddt
@skip_unless_lms
class SavedProgramReminderEmailsTest(SharedModuleStoreTestCase):
"""
Test the send_program_reminder_emails management command
"""
def setUp(self):
super().setUp()
self.uuid = '587f6abe-bfa4-4125-9fbe-4789bf3f97f1'
self.program = ProgramFactory(uuid=self.uuid)
self.saved_program = SavedPogramFactory.create(program_uuid=self.uuid)
@patch('lms.djangoapps.save_for_later.management.commands.send_program_reminder_emails.get_programs')
def test_send_reminder_emails(self, mock_get_programs):
mock_get_programs.return_value = self.program
with patch('lms.djangoapps.save_for_later.helper.BrazeClient') as mock_task:
call_command('send_program_reminder_emails', '--batch-size=1')
mock_task.assert_called()
saved_program = SavedProgram.objects.filter(program_uuid=self.uuid).first()
assert saved_program.reminder_email_sent is True
assert saved_program.email_sent_count > 0

View File

@@ -0,0 +1,66 @@
# Generated by Django 3.2.12 on 2022-03-22 16:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('save_for_later', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='savedcourse',
name='marketing_url',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='savedcourse',
name='max_effort',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='savedcourse',
name='min_effort',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='savedcourse',
name='org_img_url',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='savedcourse',
name='weeks_to_complete',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='savedcourse',
name='email_sent_count',
field=models.IntegerField(default=0, null=True),
),
migrations.AlterUniqueTogether(
name='savedcourse',
unique_together={('email', 'course_id')},
),
migrations.AddField(
model_name='savedprogram',
name='email_sent_count',
field=models.IntegerField(default=0, null=True),
),
migrations.AlterUniqueTogether(
name='savedprogram',
unique_together={('email', 'program_uuid')},
),
migrations.AddField(
model_name='savedcourse',
name='reminder_email_sent',
field=models.BooleanField(default=False, null=True),
),
migrations.AddField(
model_name='savedprogram',
name='reminder_email_sent',
field=models.BooleanField(default=False, null=True),
),
]

View File

@@ -21,6 +21,20 @@ class SavedCourse(DeletableByUserValue, TimeStampedModel):
user_id = models.IntegerField(null=True, blank=True)
email = models.EmailField(db_index=True)
course_id = CourseKeyField(max_length=255, db_index=True)
marketing_url = models.CharField(max_length=255, null=True, blank=True)
org_img_url = models.CharField(max_length=255, null=True, blank=True)
weeks_to_complete = models.IntegerField(null=True)
min_effort = models.IntegerField(null=True)
max_effort = models.IntegerField(null=True)
email_sent_count = models.IntegerField(null=True, default=0)
reminder_email_sent = models.BooleanField(default=False, null=True)
class Meta:
unique_together = ('email', 'course_id',)
def save(self, *args, **kwargs):
self.email_sent_count = self.email_sent_count + 1
super().save(*args, **kwargs)
class SavedProgram(DeletableByUserValue, TimeStampedModel):
@@ -34,3 +48,12 @@ class SavedProgram(DeletableByUserValue, TimeStampedModel):
user_id = models.IntegerField(null=True, blank=True)
email = models.EmailField(db_index=True)
program_uuid = models.UUIDField()
email_sent_count = models.IntegerField(null=True, default=0)
reminder_email_sent = models.BooleanField(default=False, null=True)
class Meta:
unique_together = ('email', 'program_uuid',)
def save(self, *args, **kwargs):
self.email_sent_count = self.email_sent_count + 1
super().save(*args, **kwargs)

View File

@@ -0,0 +1,46 @@
"""
Provides factories for save_for_later models.
"""
from datetime import datetime
from pytz import UTC
import factory
from factory.django import DjangoModelFactory
from lms.djangoapps.save_for_later.models import SavedCourse, SavedProgram
class SavedCourseFactory(DjangoModelFactory):
"""
Factory for SavedCourses objects
"""
class Meta:
model = SavedCourse
django_get_or_create = ('course_id', 'user_id')
email = 'abc@test.com'
course_id = factory.Sequence('{}'.format)
user_id = factory.Sequence('{}'.format)
reminder_email_sent = False
email_sent_count = 0
created = datetime(2020, 1, 1, tzinfo=UTC)
modified = datetime(2020, 2, 1, tzinfo=UTC)
class SavedPogramFactory(DjangoModelFactory):
"""
Factory for SavedProgram objects
"""
class Meta:
model = SavedProgram
django_get_or_create = ('program_uuid', )
email = 'abc@test.com'
user_id = factory.Sequence('{}'.format)
program_uuid = factory.Sequence('{}'.format)
reminder_email_sent = False
email_sent_count = 0
created = datetime(2020, 1, 1, tzinfo=UTC)
modified = datetime(2020, 2, 1, tzinfo=UTC)

View File

@@ -1054,6 +1054,9 @@ ENABLE_COPPA_COMPLIANCE = False
# VAN-741 - save for later api put behind a flag to make it only available for edX
ENABLE_SAVE_FOR_LATER = False
# VAN-887 - save for later reminder emails threshold days
SAVE_FOR_LATER_REMINDER_EMAIL_THRESHOLD = 15
############################# SET PATH INFORMATION #############################
PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /edx-platform/lms
REPO_ROOT = PROJECT_ROOT.dirname()