diff --git a/ecommerce/enterprise/management/commands/send_api_triggered_offer_emails.py b/ecommerce/enterprise/management/commands/send_api_triggered_offer_emails.py new file mode 100644 index 00000000000..d5e65099a0d --- /dev/null +++ b/ecommerce/enterprise/management/commands/send_api_triggered_offer_emails.py @@ -0,0 +1,300 @@ +""" +Send the enterprise offer limits emails. +""" +import logging +from datetime import datetime +from urllib.parse import urljoin + +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.management import BaseCommand +from ecommerce_worker.email.v1.api import send_api_triggered_offer_usage_email +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') +OfferUsageEmail = get_model('offer', 'OfferUsageEmail') +OrderDiscount = get_model('order', 'OrderDiscount') + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +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 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 { + 'total_limit': int(offer.max_global_applications), + 'percentage_usage': percentage_usage, + 'current_usage': int(offer.num_orders) + } + + @staticmethod + def get_booking_limits(site, offer): + """ + Return the total discount limit, percentage usage and current usage of booking limit. + """ + api_client = site.siteconfiguration.oauth_api_client + enterprise_customer_uuid = offer.condition.enterprise_customer_uuid + offer_analytics_url = urljoin( + settings.ENTERPRISE_ANALYTICS_API_URL, + f'/enterprise/api/v1/enterprise/{enterprise_customer_uuid}/offers/{offer.id}/', + ) + response = api_client.get(offer_analytics_url) + response.raise_for_status() + offer_analytics = response.json() + + 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) + + 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, + '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, + 'current_usage_str': current_usage if is_enrollment_limit_offer else "${}".format(current_usage), + } + + @staticmethod + def _get_enterprise_offers(enterprise_customer_uuid=None): + """ + Return the enterprise offers which have opted for email usage alert. + """ + filter_kwargs = { + 'emails_for_usage_alert__isnull': False, + 'condition__enterprise_customer_uuid__isnull': False, + } + + if enterprise_customer_uuid: + filter_kwargs['condition__enterprise_customer_uuid'] = enterprise_customer_uuid + + return ConditionalOffer.objects.filter(**filter_kwargs).exclude(emails_for_usage_alert='') + + def add_arguments(self, parser): + parser.add_argument( + '--enterprise-customer-uuid', + default=None, + help="Run command only for the given Customer's Offers", + ) + + def handle(self, *args, **options): + successful_send_count = 0 + enterprise_offers = self._get_enterprise_offers(options['enterprise_customer_uuid']) + 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_email(enterprise_offer): + site = Site.objects.get_current() + + try: + email_body_variables = self.get_email_content(site, enterprise_offer) + except RequestException as exc: + logger.warning( + 'Exception getting offer email content for offer %s. Exception: %s', + enterprise_offer.id, + exc, + ) + 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(',') + } + + send_api_triggered_offer_usage_email.delay( + lms_user_ids_by_email, + EMAIL_SUBJECT, + email_body_variables, + campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[email_type] + ) + + # We can't block until the task is done, because no celery backend + # is configured for ecommerce/ecommerce-worker. So there + # may be instances where an OfferUsageEmail record exists, + # but no email was really successfully sent. + successful_send_count += 1 + 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, + total_enterprise_offers_count, + ) 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 a5746ff2bfb..a6d34bb1b31 100644 --- a/ecommerce/enterprise/management/commands/send_enterprise_offer_limit_emails.py +++ b/ecommerce/enterprise/management/commands/send_enterprise_offer_limit_emails.py @@ -1,4 +1,5 @@ """ +DEPRECATED: in favor of the api-triggered version of this command. Send the enterprise offer limits emails. """ import logging @@ -9,6 +10,7 @@ from ecommerce_worker.email.v1.api import send_offer_usage_email from ecommerce.extensions.fulfillment.status import ORDER +from ecommerce.extensions.offer.constants import OfferUsageEmailTypes from ecommerce.programs.custom import get_model ConditionalOffer = get_model('offer', 'ConditionalOffer') @@ -116,7 +118,10 @@ def handle(self, *args, **options): ) send_enterprise_offer_count += 1 email_body, email_subject = self.get_email_content(enterprise_offer) - OfferUsageEmail.create_record(enterprise_offer, meta_data={ + OfferUsageEmail.create_record( + offer=enterprise_offer, + email_type=OfferUsageEmailTypes.DIGEST, + meta_data={ 'email_body': email_body, 'email_subject': email_subject, 'email_addresses': enterprise_offer.emails_for_usage_alert @@ -124,6 +129,6 @@ def handle(self, *args, **options): send_offer_usage_email.delay(enterprise_offer.emails_for_usage_alert, email_subject, email_body) logger.info( '[Offer Usage Alert] %s of %s added to the email sending queue.', + send_enterprise_offer_count, total_enterprise_offers_count, - send_enterprise_offer_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 064f0a18e71..7ca302d4206 100644 --- a/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py +++ b/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py @@ -4,22 +4,30 @@ """ import datetime import logging +from urllib.parse import urljoin import mock +import responses +from django.conf import settings from django.core.management import call_command from testfixtures import LogCapture +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 from ecommerce.tests.testcases import TestCase ConditionalOffer = get_model('offer', 'ConditionalOffer') OfferUsageEmail = get_model('offer', 'OfferUsageEmail') -LOGGER_NAME = 'ecommerce.enterprise.management.commands.send_enterprise_offer_limit_emails' +BASE_COMMAND_PATH = 'ecommerce.enterprise.management.commands' +API_TRIGGERED_PATH = BASE_COMMAND_PATH + '.send_api_triggered_offer_emails' +DEPRECATED_PATH = BASE_COMMAND_PATH + '.send_enterprise_offer_limit_emails' -class SendEnterpriseOfferLimitEmailsTests(TestCase): +class SendEnterpriseOfferLimitEmailsTests(TestCase, SiteMixin, EnterpriseServiceMockMixin): """ Tests the sending the enterprise offer limit emails command. """ @@ -30,14 +38,244 @@ def setUp(self): """ super(SendEnterpriseOfferLimitEmailsTests, self).setUp() - EnterpriseOfferFactory(max_global_applications=10) - EnterpriseOfferFactory(max_discount=100) + self.mock_access_token_response() + + 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(API_TRIGGERED_PATH + '.send_api_triggered_offer_usage_email.delay') as mock_send_email: + mock_send_email.return_value = mock.Mock() + call_command('send_api_triggered_offer_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( + {'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( + {'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] + ), + ]) + + @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(API_TRIGGERED_PATH + '.send_api_triggered_offer_usage_email.delay') as mock_send_email: + mock_send_email.return_value = mock.Mock() + call_command('send_api_triggered_offer_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( + {'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] + ), + ]) + + @responses.activate + def test_digest_email(self): + """ + Test the send_api_triggered_offer_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. EnterpriseOfferFactory(max_global_applications=0) EnterpriseOfferFactory(max_discount=0) # Creating conditionaloffer with daily frequency and adding corresponding offer_usage object. offer_with_daily_frequency = EnterpriseOfferFactory(max_global_applications=10) - offer_usage = OfferUsageEmail.create_record(offer_with_daily_frequency) + 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() @@ -46,7 +284,7 @@ def setUp(self): max_global_applications=10, usage_email_frequency=ConditionalOffer.WEEKLY ) - offer_usage = OfferUsageEmail.create_record(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() @@ -55,37 +293,159 @@ def setUp(self): max_global_applications=10, usage_email_frequency=ConditionalOffer.MONTHLY ) - offer_usage = OfferUsageEmail.create_record(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() - def test_command(self): + # Add an offer that is eligible for the usage email, + # 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. + offer_with_404 = 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({ + admin_email_1: 22, + admin_email_2: 44, + }) + + # 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(API_TRIGGERED_PATH + '.send_api_triggered_offer_usage_email.delay') as mock_send_email: + mock_send_email.return_value = mock.Mock() + call_command('send_api_triggered_offer_emails') + # 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', + { + '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( + {'example_1@example.com': 44, ' example_2@example.com': 44}, + 'Offer Usage Notification', + { + '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( + {'example_1@example.com': 44, ' example_2@example.com': 44}, + 'Offer Usage Notification', + { + '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( + {'example_1@example.com': 44, ' example_2@example.com': 44}, + 'Offer Usage Notification', + { + '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( + {'example_1@example.com': 44, ' example_2@example.com': 44}, + 'Offer Usage Notification', + { + '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] + ), + ]) + + @responses.activate + def test_command_single_enterprise(self): """ - Test the send_enterprise_offer_limit_emails command + Test the send_api_triggered_offer_emails command on a single enterprise customer. """ + + offer_1 = EnterpriseOfferFactory(max_discount=100) offer_usage_count = OfferUsageEmail.objects.all().count() - cmd_path = 'ecommerce.enterprise.management.commands.send_enterprise_offer_limit_emails' - with mock.patch(cmd_path + '.send_offer_usage_email.delay') as mock_send_email: + 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, + }) + + customer_uuid = offer_1.condition.enterprise_customer_uuid + self.mock_offer_analytics_response(customer_uuid, offer_1.id) + + with mock.patch(API_TRIGGERED_PATH + '.send_api_triggered_offer_usage_email.delay') as mock_send_email: + mock_send_email.return_value = mock.Mock() + call_command('send_api_triggered_offer_emails', enterprise_customer_uuid=customer_uuid) + assert mock_send_email.call_count == 1 + assert OfferUsageEmail.objects.all().count() == offer_usage_count + 1 + + mock_send_email.assert_has_calls([ + mock.call( + {'example_1@example.com': 22, ' example_2@example.com': 44}, + 'Offer Usage Notification', + { + '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] + ), + ]) + + def test_deprecated_command(self): + """ + Test the deprecated version of the command. + """ + ConditionalOffer.objects.all().delete() + OfferUsageEmail.objects.all().delete() + + offer_1 = EnterpriseOfferFactory(max_discount=100) + + with mock.patch(DEPRECATED_PATH + '.send_offer_usage_email.delay') as mock_send_email: with LogCapture(level=logging.INFO) as log: mock_send_email.return_value = mock.Mock() call_command('send_enterprise_offer_limit_emails') - assert mock_send_email.call_count == 5 - assert OfferUsageEmail.objects.all().count() == offer_usage_count + 5 + assert mock_send_email.call_count == 1 + assert OfferUsageEmail.objects.all().count() == 1 log.check_present( ( - LOGGER_NAME, + DEPRECATED_PATH, 'INFO', '[Offer Usage Alert] Total count of enterprise offers is {total_enterprise_offers_count}.'.format( - total_enterprise_offers_count=7 + total_enterprise_offers_count=1 ) ), ( - LOGGER_NAME, + DEPRECATED_PATH, 'INFO', '[Offer Usage Alert] {total_enterprise_offers_count} of {send_enterprise_offer_count} added to the' ' email sending queue.'.format( - total_enterprise_offers_count=7, - send_enterprise_offer_count=5 + total_enterprise_offers_count=1, + send_enterprise_offer_count=1, ) ) ) 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 5f84fec7072..a0b0889863f 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 @@ -667,6 +668,8 @@ ENTERPRISE_CATALOG_SERVICE_URL = 'http://localhost:18160/' +ENTERPRISE_ANALYTICS_API_URL = 'http://localhost:19001' + ENTERPRISE_LEARNER_PORTAL_HOSTNAME = os.environ.get('ENTERPRISE_LEARNER_PORTAL_HOSTNAME', 'localhost:8734') # Name for waffle switch to use for enabling enterprise features on runtime. @@ -850,3 +853,15 @@ ENTERPRISE_EMAIL_FILE_ATTACHMENTS_BUCKET_NAME = '' ENTERPRISE_EMAIL_FILE_ATTACHMENTS_BUCKET_LOCATION = 'us-east-1' # change this when developing with your own bucket +# If True, will send offer limit emails via a Braze API-triggered campaign +ENTERPRISE_OFFER_EMAIL_USE_API_TRIGGER = False + +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 +} diff --git a/ecommerce/settings/devstack.py b/ecommerce/settings/devstack.py index 7542fb9b69b..5d58eae4dcd 100644 --- a/ecommerce/settings/devstack.py +++ b/ecommerce/settings/devstack.py @@ -64,6 +64,8 @@ ENTERPRISE_CATALOG_API_URL = urljoin(f"{ENTERPRISE_CATALOG_SERVICE_URL}/", 'api/v1/') +ENTERPRISE_ANALYTICS_API_URL = 'http://edx.devstack.analyticsapi:19001' + # PAYMENT PROCESSING PAYMENT_PROCESSOR_CONFIG = { 'edx': { diff --git a/requirements/base.txt b/requirements/base.txt index 077607cba35..2223aa50a93 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -214,7 +214,7 @@ edx-drf-extensions==6.6.0 # -c requirements/pins.txt # -r requirements/base.in # edx-rbac -edx-ecommerce-worker==3.1.2 +edx-ecommerce-worker==3.2.0 # via -r requirements/base.in edx-opaque-keys==2.2.2 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index 8f0f0c4b6b8..dff3020aa1e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -302,7 +302,7 @@ edx-drf-extensions==6.6.0 # via # -r requirements/test.txt # edx-rbac -edx-ecommerce-worker==3.1.2 +edx-ecommerce-worker==3.2.0 # via -r requirements/test.txt edx-i18n-tools==0.8.1 # via -r requirements/test.txt diff --git a/requirements/production.txt b/requirements/production.txt index 687cf4a885c..7ad5acf92e1 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -219,7 +219,7 @@ edx-drf-extensions==6.6.0 # -c requirements/pins.txt # -r requirements/base.in # edx-rbac -edx-ecommerce-worker==3.1.2 +edx-ecommerce-worker==3.2.0 # via -r requirements/base.in edx-opaque-keys==2.2.2 # via diff --git a/requirements/test.txt b/requirements/test.txt index b06edda4433..0e80f192b95 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -303,7 +303,7 @@ edx-drf-extensions==6.6.0 # -c requirements/pins.txt # -r requirements/base.txt # edx-rbac -edx-ecommerce-worker==3.1.2 +edx-ecommerce-worker==3.2.0 # via -r requirements/base.txt edx-i18n-tools==0.8.1 # via -r requirements/test.in