diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ddc2a944ca7..9e48086f964 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -26,25 +26,39 @@ jobs: console.log('Will use tag: ' + tagName); return tagName; result-encoding: string + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build and push Dev Docker image - uses: docker/build-push-action@v1 + uses: docker/build-push-action@v4 with: push: true - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} target: dev repository: edxops/ecommerce-dev - tags: ${{ steps.get-tag-name.outputs.result }},${{ github.sha }} + tags: | + edxops/ecommerce-dev:${{ steps.get-tag-name.outputs.result }} + edxops/ecommerce-dev:${{ github.sha }} + platforms: linux/amd64,linux/arm64 # The current priority is to get the devstack off of Ansible based Images. Once that is done, we can come back to this part to get # suitable images for smaller prod environments. # - name: Build and push prod Docker image - # uses: docker/build-push-action@v1 + # uses: docker/build-push-action@v4 # with: # push: true - # username: ${{ secrets.DOCKERHUB_USERNAME }} - # password: ${{ secrets.DOCKERHUB_PASSWORD }} # target: prod # repository: edxops/ecommerce-prod - # tags: ${{ steps.get-tag-name.outputs.result }},${{ github.sha }} + # tags: | + # edxops/ecommerce-prod:${{ steps.get-tag-name.outputs.result }} + # edxops/ecommerce-prod:${{ github.sha }} + # platforms: linux/amd64,linux/arm64 diff --git a/.readthedocs.yml b/.readthedocs.yaml similarity index 100% rename from .readthedocs.yml rename to .readthedocs.yaml 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/core/management/commands/tests/test_unenroll_refunded_android_users.py b/ecommerce/core/management/commands/tests/test_unenroll_refunded_android_users.py new file mode 100644 index 00000000000..4c0af8550df --- /dev/null +++ b/ecommerce/core/management/commands/tests/test_unenroll_refunded_android_users.py @@ -0,0 +1,52 @@ +""" +Tests for Django management command to un-enroll refunded android users. +""" +from django.core.management import call_command +from mock import patch +from testfixtures import LogCapture + +from ecommerce.tests.testcases import TestCase + + +class TestUnenrollRefundedAndroidUsersCommand(TestCase): + + LOGGER_NAME = 'ecommerce.core.management.commands.unenroll_refunded_android_users' + + @patch('requests.get') + def test_handle_pass(self, mock_response): + """ Test using mock response from setup, using threshold it will clear""" + + mock_response.return_value.status_code = 200 + + with LogCapture(self.LOGGER_NAME) as log: + call_command('unenroll_refunded_android_users') + + log.check( + ( + self.LOGGER_NAME, + 'INFO', + 'Sending request to un-enroll refunded android users' + ) + ) + + @patch('requests.get') + def test_handle_fail(self, mock_response): + """ Test using mock response from setup, using threshold it will clear""" + + mock_response.return_value.status_code = 400 + + with LogCapture(self.LOGGER_NAME) as log: + call_command('unenroll_refunded_android_users') + + log.check( + ( + self.LOGGER_NAME, + 'INFO', + 'Sending request to un-enroll refunded android users' + ), + ( + self.LOGGER_NAME, + 'ERROR', + 'Failed to refund android users with status code 400' + ) + ) diff --git a/ecommerce/core/management/commands/unenroll_refunded_android_users.py b/ecommerce/core/management/commands/unenroll_refunded_android_users.py new file mode 100644 index 00000000000..2a9c34f4188 --- /dev/null +++ b/ecommerce/core/management/commands/unenroll_refunded_android_users.py @@ -0,0 +1,27 @@ +""" +Django management command to un-enroll refunded android users. + +Command is run by Jenkins job daily. +""" +import logging + +import requests +from django.core.management.base import BaseCommand +from rest_framework import status + +from ecommerce.core.models import SiteConfiguration + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Management command to un-enroll refunded android users.' + + def handle(self, *args, **options): + site = SiteConfiguration.objects.first() + refund_api_url = '{}/api/iap/v1/android/refund/'.format(site.build_ecommerce_url()) + logger.info("Sending request to un-enroll refunded android users") + response = requests.get(refund_api_url) + + if response.status_code != status.HTTP_200_OK: + logger.error("Failed to refund android users with status code %s", response.status_code) 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"