From 90fa0687aabb6a1b3976841ac372a24013bcd8f0 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Wed, 10 Jan 2024 11:05:41 -0500 Subject: [PATCH] feat: send an action_required_by prop for assignment braze tasks ENT-8146 | Send an `action_required_by` property for assignment braze notification and reminder campaigns. The value is the min of the enrollment deadline, subsidy expiration, and auto-cancellation date. --- .../content_metadata_api.py | 7 +- .../apps/content_assignments/tasks.py | 58 +++++- .../content_assignments/tests/test_tasks.py | 165 ++++++++++++++++-- .../apps/subsidy_access_policy/models.py | 23 ++- .../apps/subsidy_access_policy/subsidy_api.py | 31 ++++ enterprise_access/settings/base.py | 3 + 6 files changed, 267 insertions(+), 20 deletions(-) diff --git a/enterprise_access/apps/content_assignments/content_metadata_api.py b/enterprise_access/apps/content_assignments/content_metadata_api.py index 11187025..0a8fd1b4 100644 --- a/enterprise_access/apps/content_assignments/content_metadata_api.py +++ b/enterprise_access/apps/content_assignments/content_metadata_api.py @@ -12,6 +12,7 @@ '%Y-%m-%d %H:%M:%SZ', '%Y-%m-%d %H:%M:%S.%fZ', ] +DEFAULT_STRFTIME_PATTERN = '%b %d, %Y' def get_content_metadata_for_assignments(enterprise_catalog_uuid, assignments): @@ -48,7 +49,7 @@ def get_card_image_url(content_metadata): return None -def get_human_readable_date(datetime_string, output_pattern='%b %d, %Y'): +def get_human_readable_date(datetime_string, output_pattern=DEFAULT_STRFTIME_PATTERN): """ Given a datetime string value from some content metadata record, convert it to the provided pattern. @@ -79,6 +80,10 @@ def parse_datetime_string(datetime_string): return None +def format_datetime_obj(datetime_obj, output_pattern=DEFAULT_STRFTIME_PATTERN): + return datetime_obj.strftime(output_pattern) + + def get_course_partners(course_metadata): """ Returns a list of course partner data for subsidy requests given a course dictionary. diff --git a/enterprise_access/apps/content_assignments/tasks.py b/enterprise_access/apps/content_assignments/tasks.py index 629b0013..d784bf6a 100644 --- a/enterprise_access/apps/content_assignments/tasks.py +++ b/enterprise_access/apps/content_assignments/tasks.py @@ -3,19 +3,23 @@ """ import logging +from datetime import datetime from braze.exceptions import BrazeBadRequestError from celery import shared_task from django.apps import apps from django.conf import settings +from pytz import UTC from enterprise_access.apps.api_client.braze_client import ENTERPRISE_BRAZE_ALIAS_LABEL, BrazeApiClient from enterprise_access.apps.api_client.lms_client import LmsApiClient from enterprise_access.apps.content_assignments.content_metadata_api import ( + format_datetime_obj, get_card_image_url, get_content_metadata_for_assignments, get_course_partners, - get_human_readable_date + get_human_readable_date, + parse_datetime_string ) from enterprise_access.tasks import LoggedTaskWithRetry @@ -55,7 +59,8 @@ class BrazeCampaignSender: 'start_date', 'course_partner', 'course_card_image', - 'learner_portal_link' + 'learner_portal_link', + 'action_required_by', } def __init__(self, assignment): @@ -154,6 +159,15 @@ def course_metadata(self): raise Exception(msg) return self._course_metadata + @property + def subsidy_record(self): + """ + Returns a cached subsidy record for the policy related to this assignment. + """ + # send an extra cache arg so that cache keys are scoped + # to the context of braze campaign-sending. + return self.policy.subsidy_record_from_tiered_cache('braze_campaign_sender') + def get_properties(self, *property_names): """ Looks for instance methods on ``self`` that match "get_{property_name}" @@ -180,16 +194,48 @@ def get_organization(self): def get_course_title(self): return self.assignment.content_title + def _enrollment_deadline_raw(self): + return self.course_metadata.get('normalized_metadata', {}).get('enroll_by_date') + def get_enrollment_deadline(self): - return get_human_readable_date( - self.course_metadata.get('normalized_metadata', {}).get('enroll_by_date') - ) + return get_human_readable_date(self._enrollment_deadline_raw()) def get_start_date(self): return get_human_readable_date( self.course_metadata.get('normalized_metadata', {}).get('start_date') ) + def get_action_required_by(self): + """ + Returns the minimum of this assignment's auto-cancellation date, + the content's enrollment deadline, and the related policy's expiration datetime. + """ + if subsidy_record := self.subsidy_record: + subsidy_expiration = parse_datetime_string(subsidy_record.get('expiration_datetime')) or datetime.max + subsidy_expiration = subsidy_expiration.replace(tzinfo=UTC) + else: + subsidy_expiration = datetime.max.replace(tzinfo=UTC) + + enrollment_deadline = parse_datetime_string(self._enrollment_deadline_raw()) or datetime.max + enrollment_deadline = enrollment_deadline.replace(tzinfo=UTC) + + auto_cancellation_date = self.assignment.get_auto_expiration_date() or datetime.max + auto_cancellation_date = auto_cancellation_date.replace(tzinfo=UTC) + + message = ( + 'action_required_by assignment=%s: subsidy_expiration=%s, enrollment_deadline=%s, ' + 'auto_cancellation_date=%s' + ) + logger.info( + message, + self.assignment.uuid, + subsidy_expiration, + enrollment_deadline, + auto_cancellation_date, + ) + action_required_by = min(subsidy_expiration, enrollment_deadline, auto_cancellation_date) + return format_datetime_obj(action_required_by) + def get_course_partner(self): return get_course_partners(self.course_metadata) @@ -348,6 +394,7 @@ def send_reminder_email_for_pending_assignment(assignment_uuid): 'course_partner', 'course_card_image', 'learner_portal_link', + 'action_required_by', ) campaign_uuid = settings.BRAZE_ASSIGNMENT_REMINDER_NOTIFICATION_CAMPAIGN if assignment.lms_user_id is not None: @@ -390,6 +437,7 @@ def send_email_for_new_assignment(new_assignment_uuid): 'course_partner', 'course_card_image', 'learner_portal_link', + 'action_required_by', ) campaign_uuid = settings.BRAZE_ASSIGNMENT_NOTIFICATION_CAMPAIGN campaign_sender.send_campaign_message( diff --git a/enterprise_access/apps/content_assignments/tests/test_tasks.py b/enterprise_access/apps/content_assignments/tests/test_tasks.py index 3a47f69f..5240b632 100644 --- a/enterprise_access/apps/content_assignments/tests/test_tasks.py +++ b/enterprise_access/apps/content_assignments/tests/test_tasks.py @@ -7,6 +7,8 @@ import ddt from celery import states as celery_states from django.conf import settings +from django.utils.timezone import now, timedelta +from edx_django_utils.cache import TieredCache from requests.exceptions import HTTPError from rest_framework import status @@ -17,7 +19,9 @@ AssignmentActions, LearnerContentAssignmentStateChoices ) +from enterprise_access.apps.content_assignments.content_metadata_api import format_datetime_obj from enterprise_access.apps.content_assignments.tasks import ( + BrazeCampaignSender, create_pending_enterprise_learner_for_assignment_task, send_assignment_automatically_expired_email, send_cancel_email_for_pending_assignment, @@ -28,10 +32,9 @@ AssignmentConfigurationFactory, LearnerContentAssignmentFactory ) -from enterprise_access.apps.subsidy_access_policy.tests.factories import ( - AssignedLearnerCreditAccessPolicyFactory, - PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory -) +from enterprise_access.apps.subsidy_access_policy.models import REQUEST_CACHE_NAMESPACE +from enterprise_access.apps.subsidy_access_policy.tests.factories import AssignedLearnerCreditAccessPolicyFactory +from enterprise_access.cache_utils import request_cache from test_utils import APITestWithMocks TEST_ENTERPRISE_UUID = uuid4() @@ -206,6 +209,10 @@ def setUpTestData(cls): enterprise_customer_uuid=TEST_ENTERPRISE_UUID, uuid=TEST_ASSIGNMENT_UUID, ) + cls.policy = AssignedLearnerCreditAccessPolicyFactory( + assignment_configuration=cls.assignment_configuration, + spend_limit=10000000, + ) def setUp(self): super().setUp() @@ -217,7 +224,13 @@ def setUp(self): lms_user_id=TEST_LMS_USER_ID, assignment_configuration=self.assignment_configuration, ) - self.policy = PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory() + + def tearDown(self): + super().tearDown() + # Clear the subsidy record from the subsidy_access_policy request cache + # and the tiered/django-memcached cache. + request_cache(namespace=REQUEST_CACHE_NAMESPACE).clear() + TieredCache.dangerous_clear_all_tiers() @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.objects') @mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient') @@ -264,14 +277,14 @@ def test_send_cancel_email_for_pending_assignment( ) assert mock_braze_client.return_value.send_campaign_message.call_count == 1 - @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.objects') + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') @mock.patch('enterprise_access.apps.content_assignments.tasks.get_content_metadata_for_assignments') @mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient') @mock.patch('enterprise_access.apps.content_assignments.tasks.BrazeApiClient') @ddt.data(True, False) def test_send_reminder_email_for_pending_assignment( - self, is_logistrated, mock_braze_client_class, mock_lms_client, mock_get_metadata, - mock_policy_model, # pylint: disable=unused-argument + self, is_logistrated, mock_braze_client_class, mock_lms_client, + mock_get_metadata, mock_subsidy_client, ): """ Verify send_reminder_email_for_pending_assignment hits braze client with expected args @@ -307,6 +320,13 @@ def test_send_reminder_email_for_pending_assignment( 'card_image_url': 'https://itsanimage.com' } mock_get_metadata.return_value = {self.assignment.content_key: mock_metadata} + + # Set the subsidy expiration time to tomorrow + mock_subsidy = { + 'uuid': self.policy.subsidy_uuid, + 'expiration_datetime': (now() + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%SZ'), + } + mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy mock_braze_client.generate_mailto_link.return_value = f'mailto:{admin_email}' send_reminder_email_for_pending_assignment(self.assignment.uuid) @@ -338,11 +358,12 @@ def test_send_reminder_email_for_pending_assignment( 'start_date': 'Jan 01, 2020', 'course_partner': 'Smart Folks, Good People, and Fast Learners', 'course_card_image': 'https://itsanimage.com', - 'learner_portal_link': 'http://enterprise-learner-portal.example.com/test-slug' + 'learner_portal_link': 'http://enterprise-learner-portal.example.com/test-slug', + 'action_required_by': 'Jan 01, 2021', }, ) - @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.objects') + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') @mock.patch('enterprise_access.apps.content_assignments.tasks.get_content_metadata_for_assignments') @mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient') @mock.patch('enterprise_access.apps.content_assignments.tasks.BrazeApiClient') @@ -351,7 +372,7 @@ def test_send_email_for_new_assignment( mock_braze_client, mock_lms_client, mock_get_metadata, - mock_policy_model # pylint: disable=unused-argument + mock_subsidy_client, ): """ Verify send_email_for_new_assignment hits braze client with expected args @@ -385,9 +406,17 @@ def test_send_email_for_new_assignment( } mock_get_metadata.return_value = {self.assignment.content_key: mock_metadata} + # Set the subsidy expiration time to tomorrow + mock_subsidy = { + 'uuid': self.policy.subsidy_uuid, + 'expiration_datetime': (now() + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%SZ'), + } + mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy + mock_admin_mailto = f'mailto:{admin_email}' mock_braze_client.return_value.create_recipient.return_value = mock_recipient mock_braze_client.return_value.generate_mailto_link.return_value = mock_admin_mailto + send_email_for_new_assignment(self.assignment.uuid) # Make sure our LMS client got called correct times and with what we expected @@ -407,6 +436,7 @@ def test_send_email_for_new_assignment( 'course_partner': 'Smart Folks and Good People', 'course_card_image': 'https://itsanimage.com', 'learner_portal_link': '{}/{}'.format(settings.ENTERPRISE_LEARNER_PORTAL_URL, 'test-slug'), + 'action_required_by': 'Jan 01, 2021', }, ) assert mock_braze_client.return_value.send_campaign_message.call_count == 1 @@ -451,3 +481,116 @@ def test_send_assignment_automatically_expired_email( }, ) assert mock_braze_client.return_value.send_campaign_message.call_count == 1 + + @mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient') + @mock.patch('enterprise_access.apps.content_assignments.tasks.BrazeApiClient') + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') + @mock.patch('enterprise_access.apps.content_assignments.tasks.get_content_metadata_for_assignments') + def test_get_action_required_by_subsidy_expires_soonest( + # pylint: disable=unused-argument + self, mock_get_metadata, mock_subsidy_client, mock_braze_client_class, mock_lms_client_class + ): + """ + Tests that the subsidy_expiration time is returned as the earliest action required by time. + """ + # Set the metadata enroll_by_date to tomorrow + mock_metadata = { + 'key': self.assignment.content_key, + 'normalized_metadata': { + 'start_date': '2020-01-01 12:00:00Z', + 'end_date': '2022-01-01 12:00:00Z', + 'enroll_by_date': (now() + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%SZ'), + }, + } + mock_get_metadata.return_value = {self.assignment.content_key: mock_metadata} + + # Set the subsidy expiration time to yesterday + yesterday = now() - timedelta(days=1) + mock_subsidy = { + 'uuid': self.policy.subsidy_uuid, + 'expiration_datetime': yesterday.strftime('%Y-%m-%d %H:%M:%SZ'), + } + mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy + + # Add a successful notification action of now-ish + self.assignment.add_successful_notified_action() + + sender = BrazeCampaignSender(self.assignment) + action_required_by = sender.get_action_required_by() + + self.assertEqual(format_datetime_obj(yesterday), action_required_by) + + @mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient') + @mock.patch('enterprise_access.apps.content_assignments.tasks.BrazeApiClient') + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') + @mock.patch('enterprise_access.apps.content_assignments.tasks.get_content_metadata_for_assignments') + def test_get_action_required_by_enrollment_deadline_soonest( + # pylint: disable=unused-argument + self, mock_get_metadata, mock_subsidy_client, mock_braze_client_class, mock_lms_client_class + ): + """ + Tests that the enroll_by_date is returned as the earliest action required by time. + """ + yesterday = now() - timedelta(days=1) + # Set the metadata enroll_by_date to yesterday + mock_metadata = { + 'key': self.assignment.content_key, + 'normalized_metadata': { + 'start_date': '2020-01-01 12:00:00Z', + 'end_date': '2022-01-01 12:00:00Z', + 'enroll_by_date': yesterday.strftime('%Y-%m-%d %H:%M:%SZ'), + }, + } + mock_get_metadata.return_value = {self.assignment.content_key: mock_metadata} + + # Set the subsidy expiration time to tomorrow + mock_subsidy = { + 'uuid': self.policy.subsidy_uuid, + 'expiration_datetime': (now() + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%SZ'), + } + mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy + + # Add a successful notification action of now-ish + self.assignment.add_successful_notified_action() + + sender = BrazeCampaignSender(self.assignment) + action_required_by = sender.get_action_required_by() + + self.assertEqual(format_datetime_obj(yesterday), action_required_by) + + @mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient') + @mock.patch('enterprise_access.apps.content_assignments.tasks.BrazeApiClient') + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') + @mock.patch('enterprise_access.apps.content_assignments.tasks.get_content_metadata_for_assignments') + def test_get_action_required_by_auto_cancellation_soonest( # pylint: disable=unused-argument + self, mock_get_metadata, mock_subsidy_client, mock_braze_client_class, mock_lms_client_class + ): + """ + Tests that the auto-cancellation date is returned as the earliest action required by time. + """ + the_future = now() + timedelta(days=120) + # Set the metadata enroll_by_date to far in the future + mock_metadata = { + 'key': self.assignment.content_key, + 'normalized_metadata': { + 'start_date': '2020-01-01 12:00:00Z', + 'end_date': '2022-01-01 12:00:00Z', + 'enroll_by_date': the_future.strftime('%Y-%m-%d %H:%M:%SZ'), + }, + } + mock_get_metadata.return_value = {self.assignment.content_key: mock_metadata} + + # Set the subsidy expiration time to far in the future + mock_subsidy = { + 'uuid': self.policy.subsidy_uuid, + 'expiration_datetime': the_future.strftime('%Y-%m-%d %H:%M:%SZ'), + } + mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy + + # Add a successful notification action of now-ish + self.assignment.add_successful_notified_action() + + sender = BrazeCampaignSender(self.assignment) + action_required_by = sender.get_action_required_by() + + self.assertEqual(format_datetime_obj(self.assignment.get_auto_expiration_date()), action_required_by) diff --git a/enterprise_access/apps/subsidy_access_policy/models.py b/enterprise_access/apps/subsidy_access_policy/models.py index c5f87650..dc3ef461 100644 --- a/enterprise_access/apps/subsidy_access_policy/models.py +++ b/enterprise_access/apps/subsidy_access_policy/models.py @@ -50,7 +50,12 @@ SubsidyAccessPolicyLockAttemptFailed, SubsidyAPIHTTPError ) -from .subsidy_api import get_and_cache_transactions_for_learner +from .subsidy_api import ( + CACHE_MISS, + get_and_cache_transactions_for_learner, + get_tiered_cache_subsidy_record, + set_tiered_cache_subsidy_record +) from .utils import ProxyAwareHistoricalRecords, create_idempotency_key_for_transaction, get_versioned_subsidy_client REQUEST_CACHE_NAMESPACE = 'subsidy_access_policy' @@ -214,8 +219,7 @@ def subsidy_expiration_datetime(self): @property def is_subsidy_active(self): """ - Returns true if the localized current time is - between ``subsidy_active_datetime`` and ``subsidy_expiration_datetime``. + Returns true if the related subsidy record is still active. """ return self.subsidy_record().get('is_active') @@ -317,6 +321,19 @@ def subsidy_record(self): return result + def subsidy_record_from_tiered_cache(self, *cache_key_args): + """ + Retrieve this policy's corresponding subsidy record from TieredCache. + Should only be used in contexts that are ok with reading slow-moving, + possibly stale fields. + """ + cached_value = get_tiered_cache_subsidy_record(self.subsidy_uuid, *cache_key_args) + if cached_value is not CACHE_MISS: + return cached_value + record = self.subsidy_record() + set_tiered_cache_subsidy_record(record, *cache_key_args) + return record + def subsidy_balance(self): """ Returns total remaining balance for the associated subsidy ledger. diff --git a/enterprise_access/apps/subsidy_access_policy/subsidy_api.py b/enterprise_access/apps/subsidy_access_policy/subsidy_api.py index 3fe25a3f..8bc24eb1 100644 --- a/enterprise_access/apps/subsidy_access_policy/subsidy_api.py +++ b/enterprise_access/apps/subsidy_access_policy/subsidy_api.py @@ -7,6 +7,8 @@ from collections import defaultdict import requests +from django.conf import settings +from edx_django_utils.cache import TieredCache from enterprise_access.cache_utils import request_cache, versioned_cache_key @@ -17,6 +19,8 @@ REQUEST_CACHE_NAMESPACE = 'subsidy_access_policy' +CACHE_MISS = object() + class TransactionPolicyMismatchError(Exception): """ @@ -119,3 +123,30 @@ def get_redemptions_by_content_and_policy_for_learner(policies, lms_user_id): ) return result + + +def get_tiered_cache_subsidy_record(subsidy_uuid, *cache_key_args): + """ + Gets the subsidy record (a dictionary) with the given ``subsidy_uuid`` + from the TieredCache (meaning memcache) if present. + If not present, returns a ``CACHE_MISS`` object. + """ + cache_key = versioned_cache_key('get_subsidy_record', subsidy_uuid, *cache_key_args) + cached_response = TieredCache.get_cached_response(cache_key) + if cached_response.is_found: + logger.info(f"cache hit for subsidy record {subsidy_uuid} record") + return cached_response.value + + logger.info(f"cache miss for subsidy record {subsidy_uuid}") + return CACHE_MISS + + +def set_tiered_cache_subsidy_record(subsidy_record, *cache_key_args): + """ + Sets the given subsidy_record in the TieredCache by uuid and any additional + provided args. + """ + subsidy_uuid = subsidy_record['uuid'] + cache_key = versioned_cache_key('get_subsidy_record', subsidy_uuid, *cache_key_args) + logger.info(f"cache set for subsidy record {subsidy_uuid}") + TieredCache.set_all_tiers(cache_key, subsidy_record, settings.SUBSIDY_RECORD_CACHE_TIMEOUT) diff --git a/enterprise_access/settings/base.py b/enterprise_access/settings/base.py index 17a60578..66beb071 100644 --- a/enterprise_access/settings/base.py +++ b/enterprise_access/settings/base.py @@ -490,3 +490,6 @@ def root(*path_fragments): # disable indexing on history_date SIMPLE_HISTORY_DATE_INDEX = False + +# Cache timeouts +SUBSIDY_RECORD_CACHE_TIMEOUT = 60 * 5