-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add nudge braze email using commands
- Loading branch information
1 parent
432dcc2
commit 9fa20d4
Showing
2 changed files
with
316 additions
and
0 deletions.
There are no files selected for viewing
129 changes: 129 additions & 0 deletions
129
...se_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
""" | ||
Management command to automatically nudge learners enrolled in a course in advance | ||
""" | ||
|
||
import datetime | ||
import logging | ||
|
||
from django.core.management.base import BaseCommand | ||
from django.core.paginator import Paginator | ||
from django.utils import timezone | ||
|
||
from enterprise_access.apps.content_assignments.api import send_reminder_email_for_pending_assignment | ||
from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices | ||
from enterprise_access.apps.content_assignments.content_metadata_api import get_content_metadata_for_assignments | ||
from enterprise_access.apps.content_assignments.models import AssignmentConfiguration | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class Command(BaseCommand): | ||
""" | ||
Automatically nudge learners who are 'days_before_course_start_date' days away from the course start_date | ||
The default notification lead time without a provided `days_before_course_start_date` argument is 30 days | ||
""" | ||
help = ( | ||
'Spin off celery tasks to automatically send a braze email to ' | ||
'remind learners about an upcoming accepted assignment a certain number ' | ||
'of days in advanced determined by the "days_before_course_start_date" argument' | ||
) | ||
|
||
def add_arguments(self, parser): | ||
""" | ||
Entry point to add arguments. | ||
""" | ||
parser.add_argument( | ||
'--dry-run', | ||
action='store_true', | ||
dest='dry_run', | ||
default=False, | ||
help='Dry Run, print log messages without spawning the celery tasks.', | ||
) | ||
parser.add_argument( | ||
'--days_before_course_start_date', | ||
action='store_true', | ||
dest='days_before_course_start_date', | ||
default=30, | ||
help='The amount of days before the course start date to send a nudge email through braze', | ||
) | ||
|
||
@staticmethod | ||
def to_datetime(value): | ||
""" | ||
Return a datetime object of `value` if it is a str. | ||
""" | ||
if isinstance(value, str): | ||
return datetime.datetime.strptime( | ||
value, | ||
"%Y-%m-%dT%H:%M:%SZ" | ||
).replace( | ||
tzinfo=datetime.timezone.utc | ||
) | ||
|
||
return value | ||
|
||
def handle(self, *args, **options): | ||
dry_run = options['dry_run'] | ||
days_before_course_start_date = options['days_before_course_start_date'] | ||
|
||
for assignment_configuration in AssignmentConfiguration.objects.filter(active=True): | ||
subsidy_access_policy = assignment_configuration.subsidy_access_policy | ||
enterprise_catalog_uuid = subsidy_access_policy.catalog_uuid | ||
|
||
message = ( | ||
'[AUTOMATICALLY_REMIND_ACCEPTED_ASSIGNMENTS_1] Assignment Configuration. UUID: [%s], ' | ||
'Policy: [%s], Catalog: [%s], Enterprise: [%s], dry_run [%s]', | ||
) | ||
logger.info( | ||
message, | ||
assignment_configuration.uuid, | ||
subsidy_access_policy.uuid, | ||
enterprise_catalog_uuid, | ||
assignment_configuration.enterprise_customer_uuid, | ||
dry_run, | ||
) | ||
|
||
accepted_assignments = assignment_configuration.assignments.filter( | ||
state=LearnerContentAssignmentStateChoices.ACCEPTED | ||
) | ||
|
||
paginator = Paginator(accepted_assignments, 100) | ||
for page_number in paginator.page_range: | ||
assignments = paginator.page(page_number) | ||
|
||
content_metadata_for_assignments = get_content_metadata_for_assignments( | ||
enterprise_catalog_uuid, | ||
assignments | ||
) | ||
|
||
for assignment in assignments: | ||
content_metadata = content_metadata_for_assignments.get(assignment.content_key, {}) | ||
start_date = content_metadata.get('normalized_metadata', {}).get('start_date') | ||
course_type = content_metadata.get('course_type') | ||
|
||
# Determine if the day from today + days_before_course_state_date is equal to the day of the start date | ||
# If they are equal, then send the nudge email, otherwise continue | ||
datetime_start_date= self.to_datetime(start_date) | ||
start_date_from_today = timezone.now() + timezone.timedelta(days=days_before_course_start_date) | ||
|
||
can_send_nudge_notification_in_advance = datetime_start_date.day == start_date_from_today.day | ||
|
||
if course_type == 'executive-education-2u' and can_send_nudge_notification_in_advance: | ||
message = ( | ||
'[AUTOMATICALLY_REMIND_ACCEPTED_ASSIGNMENTS_2] assignment_configuration_uuid: [%s], ' | ||
'start_date: [%s], datetime_start_date: [%s], start_date_from_today: [%s], ' | ||
'days_before_course_start_date: [%s], can_send_nudge_notification_in_advance: [%s], ' | ||
'course_type: [%s], dry_run [%s]' | ||
) | ||
logger.info(message, | ||
assignment_configuration.uuid, | ||
start_date, | ||
datetime_start_date, | ||
start_date_from_today, | ||
days_before_course_start_date, | ||
can_send_nudge_notification_in_advance, | ||
course_type, | ||
dry_run, | ||
) | ||
if not dry_run: | ||
send_reminder_email_for_pending_assignment(assignment.uuid) |
187 changes: 187 additions & 0 deletions
187
...pps/content_assignments/management/commands/tests/test_automatically_nudge_assignments.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
""" | ||
Tests for `automatically_nudge_assignments` management command. | ||
""" | ||
|
||
from unittest import TestCase, mock | ||
from unittest.mock import call | ||
from uuid import uuid4 | ||
|
||
import pytest | ||
from django.core.management import call_command | ||
from django.utils import timezone | ||
|
||
from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices | ||
from enterprise_access.apps.content_assignments.management.commands import automatically_nudge_assignments | ||
from enterprise_access.apps.content_assignments.models import LearnerContentAssignment | ||
from enterprise_access.apps.content_assignments.tests.factories import ( | ||
AssignmentConfigurationFactory, | ||
LearnerContentAssignmentFactory | ||
) | ||
from enterprise_access.apps.subsidy_access_policy.tests.factories import AssignedLearnerCreditAccessPolicyFactory | ||
|
||
COMMAND_PATH = 'enterprise_access.apps.content_assignments.management.commands.automatically_nudge_assignments' | ||
|
||
|
||
@pytest.mark.django_db | ||
class TestAutomaticallyNudgeAssignmentCommand(TestCase): | ||
""" | ||
Tests `automatically_nudge_assignments` management command. | ||
""" | ||
|
||
def setUp(self): | ||
super().setUp() | ||
self.command = automatically_nudge_assignments.Command() | ||
|
||
self.enterprise_uuid = uuid4() | ||
self.assignment_configuration = AssignmentConfigurationFactory( | ||
enterprise_customer_uuid=self.enterprise_uuid, | ||
) | ||
self.assigned_learner_credit_policy = AssignedLearnerCreditAccessPolicyFactory( | ||
display_name='An assigned learner credit policy, for the test customer.', | ||
enterprise_customer_uuid=self.enterprise_uuid, | ||
active=True, | ||
assignment_configuration=self.assignment_configuration, | ||
spend_limit=10000 * 100, | ||
) | ||
|
||
self.alice_assignment = LearnerContentAssignmentFactory( | ||
assignment_configuration=self.assignment_configuration, | ||
learner_email='alice@foo.com', | ||
lms_user_id=None, | ||
content_key='edX+edXPrivacy101', | ||
content_title='edx: Privacy 101', | ||
content_quantity=-123, | ||
state=LearnerContentAssignmentStateChoices.ACCEPTED, | ||
) | ||
|
||
self.bob_assignment = LearnerContentAssignmentFactory( | ||
assignment_configuration=self.assignment_configuration, | ||
learner_email='bob@foo.com', | ||
lms_user_id=None, | ||
content_key='edX+edXAccessibility101', | ||
content_title='edx: Accessibility 101', | ||
content_quantity=-456, | ||
state=LearnerContentAssignmentStateChoices.ACCEPTED, | ||
) | ||
|
||
@mock.patch(COMMAND_PATH + '.get_content_metadata_for_assignments') | ||
@mock.patch('enterprise_access.apps.content_assignments.api.send_reminder_email_for_pending_assignment.delay') | ||
@mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') | ||
def test_command_dry_run( | ||
self, | ||
mock_subsidy_client, | ||
mock_send_reminder_email_for_pending_assignment_task, | ||
mock_content_metadata_for_assignments, | ||
): | ||
""" | ||
Verify that management command work as expected in dry run mode. | ||
""" | ||
enrollment_end = timezone.now() - timezone.timedelta(days=5) | ||
enrollment_end = enrollment_end.replace(microsecond=0) | ||
subsidy_expiry = timezone.now() + timezone.timedelta(days=5) | ||
subsidy_expiry = subsidy_expiry.replace(microsecond=0) | ||
start_date = timezone.now() + timezone.timedelta(days=30) | ||
start_date = start_date.replace(microsecond=0) | ||
end_date = timezone.now() + timezone.timedelta(days=180) | ||
end_date = end_date.replace(microsecond=0) | ||
|
||
mock_subsidy_client.retrieve_subsidy.return_value = { | ||
'enterprise_customer_uuid': str(self.enterprise_uuid), | ||
'expiration_datetime': subsidy_expiry.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'is_active': True, | ||
} | ||
mock_content_metadata_for_assignments.return_value = { | ||
'edX+edXAccessibility101': { | ||
'key': 'edX+edXAccessibility101', | ||
'normalized_metadata': { | ||
'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'enroll_by_date': enrollment_end.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'content_price': 123, | ||
}, | ||
'course_type': 'executive-education-2u', | ||
}, | ||
'edX+edXPrivacy101': { | ||
'normalized_metadata': { | ||
'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'enroll_by_date': enrollment_end.strftime("%Y-%m-%d %H:%M"), | ||
'content_price': 321, | ||
}, | ||
'course_type': 'executive-education-2u', | ||
} | ||
} | ||
|
||
all_assignment = LearnerContentAssignment.objects.all() | ||
accepted_assignments = LearnerContentAssignment.objects.filter( | ||
state=LearnerContentAssignmentStateChoices.ACCEPTED | ||
) | ||
# verify that all assignments are in `allocated` state | ||
assert all_assignment.count() == accepted_assignments.count() | ||
|
||
call_command(self.command, '--dry-run') | ||
|
||
mock_send_reminder_email_for_pending_assignment_task.assert_not_called() | ||
|
||
|
||
@mock.patch(COMMAND_PATH + '.get_content_metadata_for_assignments') | ||
@mock.patch('enterprise_access.apps.content_assignments.api.send_reminder_email_for_pending_assignment.delay') | ||
@mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') | ||
def test_command( | ||
self, | ||
mock_subsidy_client, | ||
mock_send_reminder_email_for_pending_assignment_task, | ||
mock_content_metadata_for_assignments, | ||
): | ||
""" | ||
Verify that management command work as expected in dry run mode. | ||
""" | ||
enrollment_end = timezone.now() + timezone.timedelta(days=5) | ||
enrollment_end = enrollment_end.replace(microsecond=0) | ||
subsidy_expiry = timezone.now() - timezone.timedelta(days=5) | ||
subsidy_expiry = subsidy_expiry.replace(microsecond=0) | ||
start_date = timezone.now() + timezone.timedelta(days=14) | ||
start_date = start_date.replace(microsecond=0) | ||
end_date = timezone.now() + timezone.timedelta(days=180) | ||
end_date = end_date.replace(microsecond=0) | ||
|
||
mock_subsidy_client.retrieve_subsidy.return_value = { | ||
'enterprise_customer_uuid': str(self.enterprise_uuid), | ||
'expiration_datetime': subsidy_expiry.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'is_active': True, | ||
} | ||
mock_content_metadata_for_assignments.return_value = { | ||
'edX+edXAccessibility101': { | ||
'key': 'edX+edXAccessibility101', | ||
'normalized_metadata': { | ||
'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'enroll_by_date': enrollment_end.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'content_price': 123, | ||
}, | ||
'course_type': 'executive-education-2u', | ||
}, | ||
'edX+edXPrivacy101': { | ||
'normalized_metadata': { | ||
'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), | ||
'enroll_by_date': enrollment_end.strftime("%Y-%m-%d %H:%M"), | ||
'content_price': 321, | ||
}, | ||
'course_type': 'executive-education-2u', | ||
} | ||
} | ||
|
||
all_assignment = LearnerContentAssignment.objects.all() | ||
accepted_assignments = LearnerContentAssignment.objects.filter( | ||
state=LearnerContentAssignmentStateChoices.ACCEPTED | ||
) | ||
# verify that all assignments are in `accepted` state | ||
assert all_assignment.count() == accepted_assignments.count() | ||
|
||
call_command(self.command, days_before_course_start_date=14) | ||
|
||
mock_send_reminder_email_for_pending_assignment_task.assert_has_calls([ | ||
call(self.alice_assignment.uuid), | ||
call(self.bob_assignment.uuid), | ||
]) |