From f722987d2371b845e0e1a22ad4e3541cda4ccec1 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Thu, 23 Jun 2022 15:24:42 -0400 Subject: [PATCH] feat: use a braze api-triggered campaign for ent offer usage --- .../send_enterprise_offer_limit_emails.py | 209 +++++++++-- ...test_send_enterprise_offer_limit_emails.py | 353 +++++++++++++++--- ecommerce/extensions/offer/constants.py | 13 + .../0051_offerusageemail_email_type.py | 18 + ecommerce/extensions/offer/models.py | 14 +- ecommerce/settings/base.py | 10 + 6 files changed, 517 insertions(+), 100 deletions(-) create mode 100644 ecommerce/extensions/offer/migrations/0051_offerusageemail_email_type.py diff --git a/ecommerce/enterprise/management/commands/send_enterprise_offer_limit_emails.py b/ecommerce/enterprise/management/commands/send_enterprise_offer_limit_emails.py index e70adf49da0..cd27bcb3b17 100644 --- a/ecommerce/enterprise/management/commands/send_enterprise_offer_limit_emails.py +++ b/ecommerce/enterprise/management/commands/send_enterprise_offer_limit_emails.py @@ -12,6 +12,7 @@ from requests.exceptions import RequestException from ecommerce.core.models import User +from ecommerce.extensions.offer.constants import OfferUsageEmailTypes from ecommerce.programs.custom import get_model ConditionalOffer = get_model('offer', 'ConditionalOffer') @@ -23,39 +24,27 @@ EMAIL_SUBJECT = 'Offer Usage Notification' +# Reasons why an email should not be sent +THRESHOLD_NOT_REACHED = 'Threshold not reached.' +EMAIL_SENT_BEFORE_OFFER_NOT_REPLENISHED = 'Email sent before, offer has not been replenished.' + class Command(BaseCommand): """ Send the enterprise offer limits emails. """ - @staticmethod - def is_eligible_for_alert(enterprise_offer): - """ - Return the bool whether given offer is eligible for sending the email. - """ - offer_usage = OfferUsageEmail.objects.filter(offer=enterprise_offer).last() - diff_of_days = datetime.now().toordinal() - offer_usage.created.toordinal() if offer_usage else 0 - - if not enterprise_offer.max_global_applications and not enterprise_offer.max_discount: - is_eligible = False - elif not offer_usage: - is_eligible = True - elif enterprise_offer.usage_email_frequency == ConditionalOffer.DAILY: - is_eligible = diff_of_days >= 1 - elif enterprise_offer.usage_email_frequency == ConditionalOffer.WEEKLY: - is_eligible = diff_of_days >= 7 - else: - is_eligible = diff_of_days >= 30 - return is_eligible - @staticmethod def get_enrollment_limits(offer): """ Return the total limit, percentage usage and current usage of enrollment limit. """ percentage_usage = int((offer.num_orders / offer.max_global_applications) * 100) - return int(offer.max_global_applications), percentage_usage, int(offer.num_orders) + return { + 'total_limit': int(offer.max_global_applications), + 'percentage_usage': percentage_usage, + 'current_usage': int(offer.num_orders) + } @staticmethod def get_booking_limits(site, offer): @@ -72,29 +61,158 @@ def get_booking_limits(site, offer): response.raise_for_status() offer_analytics = response.json() - return ( - offer_analytics['max_discount'], - offer_analytics['percent_of_offer_spent'], - offer_analytics['amount_of_offer_spent'], + return { + 'total_limit': offer_analytics['max_discount'], + 'percentage_usage': offer_analytics['percent_of_offer_spent'] * 100, # percent_of_offer_spent is 0-1 + 'current_usage': offer_analytics['amount_of_offer_spent'] + } + + @staticmethod + def is_eligible_for_email(enterprise_offer): + """ + Return whether given offer is eligible for sending a usage email. + """ + return enterprise_offer.max_global_applications or enterprise_offer.max_discount + + def should_send_email_type(self, enterprise_offer, email_type, total_limit): + """ + Return whether an email of the given type should be sent for the offer. + + Evaluates to True if an email of the given type has not been sent before or if the offer has been re-upped. + """ + last_email_of_type_sent = OfferUsageEmail.objects.filter(offer=enterprise_offer, email_type=email_type).last() + + if not last_email_of_type_sent: + return True + + email_metadata = last_email_of_type_sent.offer_email_metadata + + # All emails sent after 6/28/22 should have this field in the metadata + previous_usage_data = email_metadata.get('email_usage_data') + if not previous_usage_data: + return True + + # Offer limit has been increased, we can send emails again + previous_total_limit = previous_usage_data['total_limit'] + if total_limit > previous_total_limit: + return True + + return False + + def is_eligible_for_no_balance_email(self, enterprise_offer, usage_info, is_enrollment_limit_offer): + """ + Return whether an offer is eligible for the out of balance email. + """ + percentage_usage = usage_info['percentage_usage'] + total_limit = usage_info['total_limit'] + current_usage = usage_info['current_usage'] + + should_send_email = self.should_send_email_type( + enterprise_offer, + OfferUsageEmailTypes.OUT_OF_BALANCE, + total_limit + ) + + if not should_send_email: + return (False, EMAIL_SENT_BEFORE_OFFER_NOT_REPLENISHED) + + if is_enrollment_limit_offer: + return (percentage_usage == 100, THRESHOLD_NOT_REACHED) + + return (total_limit - current_usage <= 100, THRESHOLD_NOT_REACHED) + + def is_eligible_for_low_balance_email( + self, + enterprise_offer, + usage_info, + ): + """ + Return whether an offer is eligible for the low balance email. + """ + percentage_usage = usage_info['percentage_usage'] + total_limit = usage_info['total_limit'] + + return percentage_usage >= 75 and self.should_send_email_type( + enterprise_offer, OfferUsageEmailTypes.LOW_BALANCE, total_limit ) + def is_eligible_for_digest_email(self, enterprise_offer): + """ + Return whether given offer is eligible for the digest email. + """ + last_digest_email = OfferUsageEmail.objects.filter( + offer=enterprise_offer, + email_type=OfferUsageEmailTypes.DIGEST + ).last() + + diff_of_days = datetime.now().toordinal() - (last_digest_email.created.toordinal() if last_digest_email else 0) + + if enterprise_offer.usage_email_frequency == ConditionalOffer.DAILY: + return diff_of_days >= 1 + + if enterprise_offer.usage_email_frequency == ConditionalOffer.WEEKLY: + return diff_of_days >= 7 + + return diff_of_days >= 30 + + def get_email_type( + self, + enterprise_offer, + usage_info, + is_enrollment_limit_offer + ): + """ + Return the type of email that should be sent for the offer. + + Evaluates to None if an email should not be sent. + """ + eligible_for_no_balance_email, ineligible_for_no_balance_email_reason = self.is_eligible_for_no_balance_email( + enterprise_offer, usage_info, is_enrollment_limit_offer + ) + + if eligible_for_no_balance_email: + return OfferUsageEmailTypes.OUT_OF_BALANCE + + # Don't send low balance email or digest email until offer has been reupped + if ineligible_for_no_balance_email_reason == EMAIL_SENT_BEFORE_OFFER_NOT_REPLENISHED: + return None + + if self.is_eligible_for_low_balance_email(enterprise_offer, usage_info): + return OfferUsageEmailTypes.LOW_BALANCE + + if self.is_eligible_for_digest_email(enterprise_offer): + return OfferUsageEmailTypes.DIGEST + + return None + def get_email_content(self, site, offer): """ Return the appropriate email body and subject of given offer. """ is_enrollment_limit_offer = bool(offer.max_global_applications) - total_limit, percentage_usage, current_usage = ( + + usage_info = ( self.get_enrollment_limits(offer) if is_enrollment_limit_offer else self.get_booking_limits(site, offer) ) + total_limit = usage_info['total_limit'] + percentage_usage = usage_info['percentage_usage'] + current_usage = usage_info['current_usage'] + + email_type = self.get_email_type(offer, usage_info, is_enrollment_limit_offer) + return { + 'email_type': email_type, 'percent_usage': percentage_usage, - 'total_limit': total_limit if is_enrollment_limit_offer else "${}".format(total_limit), + 'is_enrollment_limit_offer': is_enrollment_limit_offer, + 'total_limit': total_limit, + 'total_limit_str': total_limit if is_enrollment_limit_offer else "${}".format(total_limit), 'offer_type': 'Enrollment' if is_enrollment_limit_offer else 'Booking', 'offer_name': offer.name, - 'current_usage': current_usage if is_enrollment_limit_offer else "${}".format(current_usage), + 'current_usage': current_usage, + 'current_usage_str': current_usage if is_enrollment_limit_offer else "${}".format(current_usage), } @staticmethod @@ -125,13 +243,9 @@ def handle(self, *args, **options): total_enterprise_offers_count = enterprise_offers.count() logger.info('[Offer Usage Alert] Total count of enterprise offers is %s.', total_enterprise_offers_count) for enterprise_offer in enterprise_offers: - if self.is_eligible_for_alert(enterprise_offer): - logger.info( - '[Offer Usage Alert] Sending email for Offer with Name %s, ID %s', - enterprise_offer.name, - enterprise_offer.id - ) + if self.is_eligible_for_email(enterprise_offer): site = Site.objects.get_current() + try: email_body_variables = self.get_email_content(site, enterprise_offer) except RequestException as exc: @@ -142,6 +256,18 @@ def handle(self, *args, **options): ) continue + email_type = email_body_variables['email_type'] + + if email_type is None: + continue + + logger.info( + '[Offer Usage Alert] Sending %s email for Offer with Name %s, ID %s', + email_type, + enterprise_offer.name, + enterprise_offer.id + ) + lms_user_ids_by_email = { user_email: User.get_lms_user_attribute_using_email(site, user_email, attribute='id') for user_email in enterprise_offer.emails_for_usage_alert.strip().split(',') @@ -151,18 +277,23 @@ def handle(self, *args, **options): lms_user_ids_by_email, EMAIL_SUBJECT, email_body_variables, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[email_type] ) + # Block until the task is done, since we're inside a management command # and likely running from a job scheduler (ex. Jenkins). # propagate=False means we won't re-raise (and exit this method) if any one task fails. task_result.get(propagate=False) if task_result.successful(): successful_send_count += 1 - OfferUsageEmail.create_record(enterprise_offer, meta_data={ - 'email_usage_data': email_body_variables, - 'email_subject': EMAIL_SUBJECT, - 'email_addresses': enterprise_offer.emails_for_usage_alert - }) + OfferUsageEmail.create_record( + email_type=email_type, + offer=enterprise_offer, + meta_data={ + 'email_usage_data': email_body_variables, + 'email_subject': EMAIL_SUBJECT, + 'email_addresses': enterprise_offer.emails_for_usage_alert + }) logger.info( '[Offer Usage Alert] %s of %s offers with usage alerts configured had an email sent.', successful_send_count, diff --git a/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py b/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py index 59b2978748c..cb0578168c5 100644 --- a/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py +++ b/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py @@ -11,6 +11,7 @@ from django.core.management import call_command from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin +from ecommerce.extensions.offer.constants import OfferUsageEmailTypes from ecommerce.extensions.test.factories import EnterpriseOfferFactory from ecommerce.programs.custom import get_model from ecommerce.tests.mixins import SiteMixin @@ -36,8 +37,243 @@ def setUp(self): self.mock_access_token_response() - self.offer_1 = EnterpriseOfferFactory(max_discount=100) - self.offer_2 = EnterpriseOfferFactory(max_discount=100) + def mock_lms_user_responses(self, user_ids_by_email): + api_url = urljoin(f"{self.site.siteconfiguration.user_api_url}/", "accounts/search_emails") + + for _, user_id in user_ids_by_email.items(): + responses.add( + responses.POST, + api_url, + json=[{'id': user_id}], + content_type='application/json', + ) + + def mock_offer_analytics_response( + self, + enterprise_uuid, + offer_id, + max_discount=10000.0, + amount_of_offer_spent=5000.0 + ): + route = f'/enterprise/api/v1/enterprise/{enterprise_uuid}/offers/{offer_id}/' + api_url = f'{settings.ENTERPRISE_ANALYTICS_API_URL}{route}' + responses.add( + responses.GET, + api_url, + json={ + 'max_discount': max_discount, + 'percent_of_offer_spent': (amount_of_offer_spent / max_discount), + 'amount_of_offer_spent': amount_of_offer_spent, + }, + content_type='application/json', + ) + + @responses.activate + def test_low_balance_email(self): + admin_email = 'example_1@example.com' + + offer_with_low_balance = EnterpriseOfferFactory(max_discount=100, emails_for_usage_alert=admin_email) + + offer_with_low_balance_email_sent_before = EnterpriseOfferFactory( + max_discount=100, emails_for_usage_alert=admin_email + ) + OfferUsageEmail.create_record( + OfferUsageEmailTypes.LOW_BALANCE, + offer_with_low_balance_email_sent_before, + { + 'email_usage_data': { + 'total_limit': 10000.0 + } + } + ).save() + + replenished_offer_with_low_balance_email_sent_before = EnterpriseOfferFactory( + max_discount=100, + emails_for_usage_alert=admin_email, + ) + OfferUsageEmail.create_record( + OfferUsageEmailTypes.LOW_BALANCE, + replenished_offer_with_low_balance_email_sent_before, + { + 'email_usage_data': { + 'total_limit': 10000.0 + } + } + ).save() + + self.mock_lms_user_responses({ + admin_email: 22, + }) + + self.mock_offer_analytics_response( + offer_with_low_balance.condition.enterprise_customer_uuid, + offer_with_low_balance.id, + amount_of_offer_spent=7500.0 + ) + + # low balance email sent before, should result in digest email + self.mock_offer_analytics_response( + offer_with_low_balance_email_sent_before.condition.enterprise_customer_uuid, + offer_with_low_balance_email_sent_before.id, + amount_of_offer_spent=7500.0 + ) + + # low balance email sent before but offer is replenished, should send low balance email again + self.mock_offer_analytics_response( + replenished_offer_with_low_balance_email_sent_before.condition.enterprise_customer_uuid, + replenished_offer_with_low_balance_email_sent_before.id, + amount_of_offer_spent=15000.0, + max_discount=20000.0 + ) + + with mock.patch(COMMAND_PATH + '.send_offer_usage_email.delay') as mock_send_email: + mock_send_email.return_value = mock.Mock() + call_command('send_enterprise_offer_limit_emails') + assert mock_send_email.call_count == 3 + + mock_send_email.assert_has_calls([ + mock.call( + {'example_1@example.com': 22}, + 'Offer Usage Notification', + { + 'email_type': OfferUsageEmailTypes.LOW_BALANCE, 'is_enrollment_limit_offer': False, + 'percent_usage': 75.0, 'total_limit': 10000.0, 'total_limit_str': '$10000.0', + 'offer_type': 'Booking', 'offer_name': offer_with_low_balance.name, + 'current_usage': 7500.0, 'current_usage_str': '$7500.0', + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.LOW_BALANCE] + ), + mock.call().get(propagate=False), + mock.call().successful(), + mock.call( + {'example_1@example.com': 22}, + 'Offer Usage Notification', + { + 'email_type': OfferUsageEmailTypes.DIGEST, 'is_enrollment_limit_offer': False, + 'percent_usage': 75.0, 'total_limit': 10000.0, 'total_limit_str': '$10000.0', + 'offer_type': 'Booking', 'offer_name': offer_with_low_balance_email_sent_before.name, + 'current_usage': 7500.0, 'current_usage_str': '$7500.0' + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] + ), + mock.call().get(propagate=False), + mock.call().successful(), + mock.call( + {'example_1@example.com': 22}, + 'Offer Usage Notification', + { + 'email_type': OfferUsageEmailTypes.LOW_BALANCE, 'is_enrollment_limit_offer': False, + 'percent_usage': 75.0, 'total_limit': 20000.0, 'total_limit_str': '$20000.0', + 'offer_type': 'Booking', + 'offer_name': replenished_offer_with_low_balance_email_sent_before.name, + 'current_usage': 15000.0, 'current_usage_str': '$15000.0' + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.LOW_BALANCE] + ), + mock.call().get(propagate=False), + mock.call().successful(), + ]) + + @responses.activate + def test_no_balance_email(self): + admin_email = 'example_1@example.com' + + offer_with_no_balance = EnterpriseOfferFactory(max_discount=100, emails_for_usage_alert=admin_email) + + offer_with_no_balance_email_sent_before = EnterpriseOfferFactory( + max_discount=100, emails_for_usage_alert=admin_email + ) + + OfferUsageEmail.create_record( + OfferUsageEmailTypes.OUT_OF_BALANCE, + offer_with_no_balance_email_sent_before, + { + 'email_usage_data': { + 'total_limit': 10000.0 + } + } + ).save() + + replenished_offer_with_no_balance_email_sent_before = EnterpriseOfferFactory( + max_discount=100, + emails_for_usage_alert=admin_email, + ) + OfferUsageEmail.create_record( + OfferUsageEmailTypes.OUT_OF_BALANCE, + replenished_offer_with_no_balance_email_sent_before, + { + 'email_usage_data': { + 'total_limit': 10000.0 + } + } + ).save() + + self.mock_lms_user_responses({ + admin_email: 22, + }) + + self.mock_offer_analytics_response( + offer_with_no_balance.condition.enterprise_customer_uuid, + offer_with_no_balance.id, + amount_of_offer_spent=9900.0 + ) + + # no balance email sent before, should result in no email + self.mock_offer_analytics_response( + offer_with_no_balance_email_sent_before.condition.enterprise_customer_uuid, + offer_with_no_balance_email_sent_before.id, + amount_of_offer_spent=9900.0 + ) + + # no balance email sent before but offer is replenished, should send no balance email again + self.mock_offer_analytics_response( + replenished_offer_with_no_balance_email_sent_before.condition.enterprise_customer_uuid, + replenished_offer_with_no_balance_email_sent_before.id, + amount_of_offer_spent=19900.0, + max_discount=20000.0 + ) + + with mock.patch(COMMAND_PATH + '.send_offer_usage_email.delay') as mock_send_email: + mock_send_email.return_value = mock.Mock() + call_command('send_enterprise_offer_limit_emails') + assert mock_send_email.call_count == 2 + mock_send_email.assert_has_calls([ + mock.call( + {'example_1@example.com': 22}, + 'Offer Usage Notification', + { + 'email_type': OfferUsageEmailTypes.OUT_OF_BALANCE, 'is_enrollment_limit_offer': False, + 'percent_usage': 99.0, 'total_limit_str': '$10000.0', 'total_limit': 10000.0, + 'offer_type': 'Booking', 'offer_name': offer_with_no_balance.name, + 'current_usage': 9900.0, 'current_usage_str': '$9900.0' + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.OUT_OF_BALANCE] + ), + mock.call().get(propagate=False), + mock.call().successful(), + mock.call( + {'example_1@example.com': 22}, + 'Offer Usage Notification', + { + 'email_type': OfferUsageEmailTypes.OUT_OF_BALANCE, 'is_enrollment_limit_offer': False, + 'percent_usage': 99.5, 'total_limit_str': '$20000.0', 'total_limit': 20000.0, + 'offer_type': 'Booking', 'offer_name': replenished_offer_with_no_balance_email_sent_before.name, + 'current_usage': 19900.0, 'current_usage_str': '$19900.0' + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.OUT_OF_BALANCE] + ), + mock.call().get(propagate=False), + mock.call().successful(), + ]) + + @responses.activate + def test_digest_email(self): + """ + Test the send_enterprise_offer_limit_emails command + """ + + offer_1 = EnterpriseOfferFactory(max_discount=100) + offer_2 = EnterpriseOfferFactory(max_discount=100) # Make two more offers that are not eligible for alerts # by setting their max applications/discounts to 0. @@ -45,26 +281,26 @@ def setUp(self): EnterpriseOfferFactory(max_discount=0) # Creating conditionaloffer with daily frequency and adding corresponding offer_usage object. - self.offer_with_daily_frequency = EnterpriseOfferFactory(max_global_applications=10) - offer_usage = OfferUsageEmail.create_record(self.offer_with_daily_frequency) + offer_with_daily_frequency = EnterpriseOfferFactory(max_global_applications=10) + offer_usage = OfferUsageEmail.create_record(OfferUsageEmailTypes.DIGEST, offer_with_daily_frequency) offer_usage.created = datetime.datetime.fromordinal(datetime.datetime.now().toordinal() - 2) offer_usage.save() # Creating conditionaloffer with weekly frequency and adding corresponding offer_usage object. - self.offer_with_weekly_frequency = EnterpriseOfferFactory( + offer_with_weekly_frequency = EnterpriseOfferFactory( max_global_applications=10, usage_email_frequency=ConditionalOffer.WEEKLY ) - offer_usage = OfferUsageEmail.create_record(self.offer_with_weekly_frequency) + offer_usage = OfferUsageEmail.create_record(OfferUsageEmailTypes.DIGEST, offer_with_weekly_frequency) offer_usage.created = datetime.datetime.fromordinal(datetime.datetime.now().toordinal() - 8) offer_usage.save() # Creating conditionaloffer with monthly frequency and adding corresponding offer_usage object. - self.offer_with_monthly_frequency = EnterpriseOfferFactory( + offer_with_monthly_frequency = EnterpriseOfferFactory( max_global_applications=10, usage_email_frequency=ConditionalOffer.MONTHLY ) - offer_usage = OfferUsageEmail.create_record(self.offer_with_monthly_frequency) + offer_usage = OfferUsageEmail.create_record(OfferUsageEmailTypes.DIGEST, offer_with_monthly_frequency) offer_usage.created = datetime.datetime.fromordinal(datetime.datetime.now().toordinal() - 31) offer_usage.save() @@ -72,94 +308,89 @@ def setUp(self): # but has no corresponding mock response configured, # so that it hits a 404 and is appropriately handled by the try/except # block for RequestExceptions inside the command's handle() method. - self.offer_with_404 = EnterpriseOfferFactory(max_discount=100) - - def mock_lms_user_responses(self, user_ids_by_email): - api_url = urljoin(f"{self.site.siteconfiguration.user_api_url}/", "accounts/search_emails") - - for _, user_id in user_ids_by_email.items(): - responses.add( - responses.POST, - api_url, - json=[{'id': user_id}], - content_type='application/json', - ) + offer_with_404 = EnterpriseOfferFactory(max_discount=100) - def mock_offer_analytics_response(self, enterprise_uuid, offer_id): - route = f'/enterprise/api/v1/enterprise/{enterprise_uuid}/offers/{offer_id}/' - api_url = f'{settings.ENTERPRISE_ANALYTICS_API_URL}{route}' - responses.add( - responses.GET, - api_url, - json={ - 'max_discount': 10000.0, - 'percent_of_offer_spent': 50.0, - 'amount_of_offer_spent': 5000.0, - }, - content_type='application/json', - ) - - @responses.activate - def test_command(self): - """ - Test the send_enterprise_offer_limit_emails command - """ offer_usage_count = OfferUsageEmail.objects.all().count() + admin_email_1, admin_email_2 = 'example_1@example.com', 'example_2@example.com' self.mock_lms_user_responses({ admin_email_1: 22, admin_email_2: 44, }) - # Don't mock out a response for self.offer_with_404 - for offer in ConditionalOffer.objects.exclude(id=self.offer_with_404.id): + # Don't mock out a response for certain offers + for offer in ConditionalOffer.objects.exclude(id=offer_with_404.id): self.mock_offer_analytics_response(offer.condition.enterprise_customer_uuid, offer.id) with mock.patch(COMMAND_PATH + '.send_offer_usage_email.delay') as mock_send_email: mock_send_email.return_value = mock.Mock() call_command('send_enterprise_offer_limit_emails') - # if self.offer_with_404 had email content, this 5 would be a 6. + # if offer_with_404 had email content, this 5 would be a 6. assert mock_send_email.call_count == 5 assert OfferUsageEmail.objects.all().count() == offer_usage_count + 5 - mock_send_email.assert_has_calls([ mock.call( {'example_1@example.com': 22, ' example_2@example.com': 44}, 'Offer Usage Notification', - {'percent_usage': 50.0, 'total_limit': '$10000.0', 'offer_type': 'Booking', - 'offer_name': self.offer_1.name, 'current_usage': '$5000.0'} + { + 'email_type': OfferUsageEmailTypes.DIGEST, 'is_enrollment_limit_offer': False, + 'percent_usage': 50.0, 'total_limit_str': '$10000.0', 'offer_type': 'Booking', + 'total_limit': 10000.0, 'offer_name': offer_1.name, 'current_usage': 5000.0, + 'current_usage_str': '$5000.0' + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), mock.call().get(propagate=False), mock.call().successful(), mock.call( {'example_1@example.com': 44, ' example_2@example.com': 44}, 'Offer Usage Notification', - {'percent_usage': 50.0, 'total_limit': '$10000.0', 'offer_type': 'Booking', - 'offer_name': self.offer_2.name, 'current_usage': '$5000.0'} + { + 'email_type': OfferUsageEmailTypes.DIGEST, 'is_enrollment_limit_offer': False, + 'percent_usage': 50.0, 'total_limit_str': '$10000.0', 'total_limit': 10000.0, + 'offer_type': 'Booking', 'offer_name': offer_2.name, 'current_usage': 5000.0, + 'current_usage_str': '$5000.0' + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), mock.call().get(propagate=False), mock.call().successful(), mock.call( {'example_1@example.com': 44, ' example_2@example.com': 44}, 'Offer Usage Notification', - {'percent_usage': 0, 'total_limit': 10, 'offer_type': 'Enrollment', - 'offer_name': self.offer_with_daily_frequency.name, 'current_usage': 0} + { + 'email_type': OfferUsageEmailTypes.DIGEST, 'is_enrollment_limit_offer': True, + 'percent_usage': 0, 'total_limit_str': 10, 'total_limit': 10, + 'offer_type': 'Enrollment', 'offer_name': offer_with_daily_frequency.name, 'current_usage': 0, + 'current_usage_str': 0 + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), mock.call().get(propagate=False), mock.call().successful(), mock.call( {'example_1@example.com': 44, ' example_2@example.com': 44}, 'Offer Usage Notification', - {'percent_usage': 0, 'total_limit': 10, 'offer_type': 'Enrollment', - 'offer_name': self.offer_with_weekly_frequency.name, 'current_usage': 0} + { + 'email_type': OfferUsageEmailTypes.DIGEST, 'is_enrollment_limit_offer': True, + 'percent_usage': 0, 'total_limit_str': 10, 'total_limit': 10, + 'offer_type': 'Enrollment', 'offer_name': offer_with_weekly_frequency.name, 'current_usage': 0, + 'current_usage_str': 0 + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), mock.call().get(propagate=False), mock.call().successful(), mock.call( {'example_1@example.com': 44, ' example_2@example.com': 44}, 'Offer Usage Notification', - {'percent_usage': 0, 'total_limit': 10, 'offer_type': 'Enrollment', - 'offer_name': self.offer_with_monthly_frequency.name, 'current_usage': 0} + { + 'email_type': OfferUsageEmailTypes.DIGEST, 'is_enrollment_limit_offer': True, + 'percent_usage': 0, 'total_limit_str': 10, 'total_limit': 10, 'offer_type': 'Enrollment', + 'offer_name': offer_with_monthly_frequency.name, + 'current_usage': 0, 'current_usage_str': 0 + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), mock.call().get(propagate=False), mock.call().successful(), @@ -170,6 +401,8 @@ def test_command_single_enterprise(self): """ Test the send_enterprise_offer_limit_emails command on a single enterprise customer. """ + + offer_1 = EnterpriseOfferFactory(max_discount=100) offer_usage_count = OfferUsageEmail.objects.all().count() admin_email_1, admin_email_2 = 'example_1@example.com', 'example_2@example.com' self.mock_lms_user_responses({ @@ -177,13 +410,12 @@ def test_command_single_enterprise(self): admin_email_2: 44, }) - customer_uuid = self.offer_1.condition.enterprise_customer_uuid - self.mock_offer_analytics_response(customer_uuid, self.offer_1.id) + customer_uuid = offer_1.condition.enterprise_customer_uuid + self.mock_offer_analytics_response(customer_uuid, offer_1.id) with mock.patch(COMMAND_PATH + '.send_offer_usage_email.delay') as mock_send_email: mock_send_email.return_value = mock.Mock() call_command('send_enterprise_offer_limit_emails', enterprise_customer_uuid=customer_uuid) - # if self.offer_with_404 had email content, this 5 would be a 6. assert mock_send_email.call_count == 1 assert OfferUsageEmail.objects.all().count() == offer_usage_count + 1 @@ -191,8 +423,13 @@ def test_command_single_enterprise(self): mock.call( {'example_1@example.com': 22, ' example_2@example.com': 44}, 'Offer Usage Notification', - {'percent_usage': 50.0, 'total_limit': '$10000.0', 'offer_type': 'Booking', - 'offer_name': self.offer_1.name, 'current_usage': '$5000.0'} + { + 'email_type': OfferUsageEmailTypes.DIGEST, 'is_enrollment_limit_offer': False, + 'percent_usage': 50.0, 'total_limit': 10000.0, 'total_limit_str': '$10000.0', + 'offer_type': 'Booking', 'offer_name': offer_1.name, 'current_usage': 5000.0, + 'current_usage_str': '$5000.0' + }, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), mock.call().get(propagate=False), mock.call().successful(), diff --git a/ecommerce/extensions/offer/constants.py b/ecommerce/extensions/offer/constants.py index e21ff3ee28d..85b4d33c715 100644 --- a/ecommerce/extensions/offer/constants.py +++ b/ecommerce/extensions/offer/constants.py @@ -78,5 +78,18 @@ (MANUAL_EMAIL, _('Manual')), ) + +class OfferUsageEmailTypes: + DIGEST = 'digest' + LOW_BALANCE = 'low_balance' + OUT_OF_BALANCE = 'out_of_balance' + + CHOICES = ( + (DIGEST, 'Digest email'), + (LOW_BALANCE, 'Low balance email'), + (OUT_OF_BALANCE, 'Out of balance email') + ) + + # Max files size for coupon attachments: 250kb MAX_FILES_SIZE_FOR_COUPONS = 256000 diff --git a/ecommerce/extensions/offer/migrations/0051_offerusageemail_email_type.py b/ecommerce/extensions/offer/migrations/0051_offerusageemail_email_type.py new file mode 100644 index 00000000000..6f7ba5ef168 --- /dev/null +++ b/ecommerce/extensions/offer/migrations/0051_offerusageemail_email_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-06-28 21:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('offer', '0050_templatefileattachment'), + ] + + operations = [ + migrations.AddField( + model_name='offerusageemail', + name='email_type', + field=models.CharField(blank=True, choices=[('digest', 'Digest email'), ('low_balance', 'Low balance email'), ('out_of_balance', 'Out of balance email')], help_text='Which type of email was sent.', max_length=32, null=True), + ), + ] diff --git a/ecommerce/extensions/offer/models.py b/ecommerce/extensions/offer/models.py index c8a187e3fde..e42e9db9994 100644 --- a/ecommerce/extensions/offer/models.py +++ b/ecommerce/extensions/offer/models.py @@ -42,7 +42,8 @@ OFFER_ASSIGNMENT_REVOKED, OFFER_MAX_USES_DEFAULT, OFFER_REDEEMED, - SENDER_CATEGORY_TYPES + SENDER_CATEGORY_TYPES, + OfferUsageEmailTypes ) from ecommerce.extensions.offer.utils import format_assigned_offer_email @@ -790,14 +791,21 @@ def __str__(self): class OfferUsageEmail(TimeStampedModel): offer = models.ForeignKey('offer.ConditionalOffer', on_delete=models.CASCADE) + email_type = models.CharField( + max_length=32, + blank=True, + null=True, + choices=OfferUsageEmailTypes.CHOICES, + help_text=("Which type of email was sent."), + ) offer_email_metadata = JSONField(default={}) @classmethod - def create_record(cls, offer, meta_data=None): + def create_record(cls, email_type, offer, meta_data=None): """ Create object by given data. """ - record = cls(offer=offer) + record = cls(email_type=email_type, offer=offer) if meta_data: record.offer_email_metadata = meta_data record.save() diff --git a/ecommerce/settings/base.py b/ecommerce/settings/base.py index af991f23270..6cbedde9527 100644 --- a/ecommerce/settings/base.py +++ b/ecommerce/settings/base.py @@ -21,6 +21,7 @@ SYSTEM_ENTERPRISE_LEARNER_ROLE, SYSTEM_ENTERPRISE_OPERATOR_ROLE ) +from ecommerce.extensions.offer.constants import OfferUsageEmailTypes from ecommerce.settings._oscar import * # PATH CONFIGURATION @@ -853,3 +854,12 @@ ENTERPRISE_EMAIL_FILE_ATTACHMENTS_BUCKET_NAME = '' ENTERPRISE_EMAIL_FILE_ATTACHMENTS_BUCKET_LOCATION = 'us-east-1' # change this when developing with your own bucket +BRAZE_OFFER_DIGEST_CAMPAIGN = '' +BRAZE_OFFER_LOW_BALANCE_CAMPAIGN = '' +BRAZE_OFFER_NO_BALANCE_CAMPAIGN = '' + +CAMPAIGN_IDS_BY_EMAIL_TYPE = { + OfferUsageEmailTypes.DIGEST: BRAZE_OFFER_DIGEST_CAMPAIGN, + OfferUsageEmailTypes.LOW_BALANCE: BRAZE_OFFER_LOW_BALANCE_CAMPAIGN, + OfferUsageEmailTypes.OUT_OF_BALANCE: BRAZE_OFFER_NO_BALANCE_CAMPAIGN +}