diff --git a/docs/additional_features/gate_ecommerce.rst b/docs/additional_features/gate_ecommerce.rst index 4c0cdbac2ef..d0377bbcd83 100644 --- a/docs/additional_features/gate_ecommerce.rst +++ b/docs/additional_features/gate_ecommerce.rst @@ -66,6 +66,9 @@ Waffle offers the following feature gates. * - disable_redundant_payment_check_for_mobile - Switch - Enable returning an error for duplicate transaction_id for mobile in-app purchases. + * - mail_mobile_team_for_change_in_course + - Switch + - Alert mobile team for a change in a course having mobile seats, so that they can adjust prices on mobile platforms. * - enable_stripe_payment_processor - Flag - Ignore client side payment processor setting and use Stripe. For background, see `frontend-app-payment 0005-stripe-custom-actions `_. diff --git a/ecommerce/extensions/api/constatnts.py b/ecommerce/extensions/api/constatnts.py new file mode 100644 index 00000000000..1e4a5deebad --- /dev/null +++ b/ecommerce/extensions/api/constatnts.py @@ -0,0 +1,9 @@ +# .. toggle_name: mail_mobile_team_for_change_in_course +# .. toggle_type: waffle_switch +# .. toggle_default: False +# .. toggle_description: Alert mobile team for a change in a course having mobile seats. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2023-07-25 +# .. toggle_tickets: LEARNER-9377 +# .. toggle_status: supported +MAIL_MOBILE_TEAM_FOR_CHANGE_IN_COURSE = 'mail_mobile_team_for_change_in_course' diff --git a/ecommerce/extensions/api/serializers.py b/ecommerce/extensions/api/serializers.py index 5e8b29f94d5..11825a2c891 100644 --- a/ecommerce/extensions/api/serializers.py +++ b/ecommerce/extensions/api/serializers.py @@ -46,6 +46,8 @@ get_enterprise_customer_uuid_from_voucher ) from ecommerce.entitlements.utils import create_or_update_course_entitlement +from ecommerce.extensions.api.constatnts import MAIL_MOBILE_TEAM_FOR_CHANGE_IN_COURSE +from ecommerce.extensions.api.utils import send_mail_to_mobile_team_for_change_in_course from ecommerce.extensions.api.v2.constants import ( ENABLE_HOIST_ORDER_HISTORY, REFUND_ORDER_EMAIL_CLOSING, @@ -820,6 +822,13 @@ def validate_products(self, products): return products + def _get_seats_offered_on_mobile(self, course): + certificate_type_query = Q(attributes__name='certificate_type', attribute_values__value_text='verified') + mobile_query = Q(stockrecords__partner_sku__contains='mobile') + mobile_seats = course.seat_products.filter(certificate_type_query & mobile_query) + + return mobile_seats + def get_partner(self): """Validate partner""" if not self.partner: @@ -879,6 +888,10 @@ def save(self): # pylint: disable=arguments-differ published = (resp_message is None) if published: + mobile_seats = self._get_seats_offered_on_mobile(course) + if waffle.switch_is_active(MAIL_MOBILE_TEAM_FOR_CHANGE_IN_COURSE) and mobile_seats: + send_mail_to_mobile_team_for_change_in_course(course, mobile_seats) + return created, None, None raise Exception(resp_message) diff --git a/ecommerce/extensions/api/tests/test_utils.py b/ecommerce/extensions/api/tests/test_utils.py new file mode 100644 index 00000000000..cc81c0293d1 --- /dev/null +++ b/ecommerce/extensions/api/tests/test_utils.py @@ -0,0 +1,62 @@ +import mock +from testfixtures import LogCapture + +from ecommerce.courses.tests.factories import CourseFactory +from ecommerce.extensions.api.utils import send_mail_to_mobile_team_for_change_in_course +from ecommerce.extensions.iap.models import IAPProcessorConfiguration +from ecommerce.tests.testcases import TestCase + + +class UtilTests(TestCase): + def setUp(self): + super(UtilTests, self).setUp() + self.course = CourseFactory(id='test/course/123', name='Test Course 123') + seat = self.course.create_or_update_seat('verified', True, 60) + second_seat = self.course.create_or_update_seat('verified', True, 70) + self.mock_mobile_team_mail = 'abc@example.com' + self.mock_email_body = { + 'subject': 'Course Change Alert for Test Course 123', + 'body': 'Course: Test Course 123, Sku: {}, Price: 70.00\n' + 'Course: Test Course 123, Sku: {}, Price: 60.00'.format( + second_seat.stockrecords.all()[0].partner_sku, + seat.stockrecords.all()[0].partner_sku + ) + } + + def test_send_mail_to_mobile_team_with_no_email_specified(self): + logger_name = 'ecommerce.extensions.api.utils' + email_sender = 'ecommerce.extensions.communication.utils.Dispatcher.dispatch_direct_messages' + msg_t = "Couldn't mail mobile team for change in {}. No email was specified for mobile team in configurations" + msg = msg_t.format(self.course.name) + with LogCapture(logger_name) as utils_logger,\ + mock.patch(email_sender) as mock_send_email: + + send_mail_to_mobile_team_for_change_in_course(self.course, self.course.seat_products.all()) + utils_logger.check_present( + ( + logger_name, + 'INFO', + msg + ) + ) + assert mock_send_email.call_count == 0 + + def test_send_mail_to_mobile_team(self): + logger_name = 'ecommerce.extensions.api.utils' + email_sender = 'ecommerce.extensions.communication.utils.Dispatcher.dispatch_direct_messages' + iap_configs = IAPProcessorConfiguration.get_solo() + iap_configs.mobile_team_email = self.mock_mobile_team_mail + iap_configs.save() + with LogCapture(logger_name) as utils_logger,\ + mock.patch(email_sender) as mock_send_email: + + send_mail_to_mobile_team_for_change_in_course(self.course, self.course.seat_products.all()) + utils_logger.check_present( + ( + logger_name, + 'INFO', + "Sent change in {} email to mobile team.".format(self.course.name) + ) + ) + assert mock_send_email.call_count == 1 + mock_send_email.assert_called_with(self.mock_mobile_team_mail, self.mock_email_body) diff --git a/ecommerce/extensions/api/utils.py b/ecommerce/extensions/api/utils.py new file mode 100644 index 00000000000..d3ce55b71eb --- /dev/null +++ b/ecommerce/extensions/api/utils.py @@ -0,0 +1,36 @@ +import logging + +from oscar.core.loading import get_class + +from ecommerce.extensions.iap.models import IAPProcessorConfiguration + +Dispatcher = get_class('communication.utils', 'Dispatcher') +logger = logging.getLogger(__name__) + + +def send_mail_to_mobile_team_for_change_in_course(course, seats): + recipient = IAPProcessorConfiguration.get_solo().mobile_team_email + if not recipient: + msg = "Couldn't mail mobile team for change in %s. No email was specified for mobile team in configurations" + logger.info(msg, course.name) + return + + def format_seat(seat): + seat_template = "Course: {}, Sku: {}, Price: {}" + stock_record = seat.stockrecords.all()[0] + result = seat_template.format( + course.name, + stock_record.partner_sku, + stock_record.price_excl_tax, + ) + return result + + formatted_seats = [format_seat(seat) for seat in seats if seat.stockrecords.all()] + + messages = { + 'subject': 'Course Change Alert for {}'.format(course.name), + 'body': "\n".join(formatted_seats) + } + + Dispatcher().dispatch_direct_messages(recipient, messages) + logger.info("Sent change in %s email to mobile team.", course.name) diff --git a/ecommerce/extensions/iap/migrations/0006_iapprocessorconfiguration_mobile_team_email.py b/ecommerce/extensions/iap/migrations/0006_iapprocessorconfiguration_mobile_team_email.py new file mode 100644 index 00000000000..bd39130ea1c --- /dev/null +++ b/ecommerce/extensions/iap/migrations/0006_iapprocessorconfiguration_mobile_team_email.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-08-02 08:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('iap', '0005_paymentprocessorresponseextension_meta_data'), + ] + + operations = [ + migrations.AddField( + model_name='iapprocessorconfiguration', + name='mobile_team_email', + field=models.EmailField(default='', max_length=254, verbose_name='mobile team email'), + ), + ] diff --git a/ecommerce/extensions/iap/models.py b/ecommerce/extensions/iap/models.py index cf301084def..eea2659bba8 100644 --- a/ecommerce/extensions/iap/models.py +++ b/ecommerce/extensions/iap/models.py @@ -22,6 +22,12 @@ class IAPProcessorConfiguration(SingletonModel): ) ) + mobile_team_email = models.EmailField( + default='', + verbose_name=_('mobile team email'), + max_length=254 + ) + class Meta: verbose_name = "IAP Processor Configuration"