Skip to content

Commit

Permalink
feat: add nudge braze email using commands
Browse files Browse the repository at this point in the history
  • Loading branch information
brobro10000 committed Jan 22, 2024
1 parent 432dcc2 commit fc242fb
Show file tree
Hide file tree
Showing 4 changed files with 319 additions and 3 deletions.
4 changes: 2 additions & 2 deletions enterprise_access/apps/content_assignments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ def expire_assignment(assignment, content_metadata, modify_assignment=True):
current_date = now()

if auto_cancellation_date and current_date > auto_cancellation_date:
assignment_expiry_reason = AssignmentAutomaticExpiredReason.NIENTY_DAYS_PASSED
assignment_expiry_reason = AssignmentAutomaticExpiredReason.NINETY_DAYS_PASSED
elif enrollment_end_date and enrollment_end_date < current_date:
assignment_expiry_reason = AssignmentAutomaticExpiredReason.ENROLLMENT_DATE_PASSED
elif subsidy_expiration_datetime and subsidy_expiration_datetime < current_date:
Expand All @@ -612,7 +612,7 @@ def expire_assignment(assignment, content_metadata, modify_assignment=True):
logger.info('Modifying assignment %s to expired', assignment.uuid)
assignment.state = LearnerContentAssignmentStateChoices.CANCELLED

if assignment_expiry_reason == AssignmentAutomaticExpiredReason.NIENTY_DAYS_PASSED:
if assignment_expiry_reason == AssignmentAutomaticExpiredReason.NINETY_DAYS_PASSED:
assignment.clear_pii()
assignment.clear_historical_pii()

Expand Down
2 changes: 1 addition & 1 deletion enterprise_access/apps/content_assignments/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class AssignmentAutomaticExpiredReason:
"""
Reason for assignment automatic expiry.
"""
NIENTY_DAYS_PASSED = 'NIENTY_DAYS_PASSED'
NINETY_DAYS_PASSED = 'NINETY_DAYS_PASSED'
ENROLLMENT_DATE_PASSED = 'ENROLLMENT_DATE_PASSED'
SUBSIDY_EXPIRED = 'SUBSIDY_EXPIRED'

Expand Down
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.delay(assignment.uuid)
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),
])

0 comments on commit fc242fb

Please sign in to comment.