From e233314e4b80671c36cbd3b7f62a72a16f826f43 Mon Sep 17 00:00:00 2001 From: jawad khan Date: Thu, 3 Aug 2023 22:17:11 +0500 Subject: [PATCH 1/4] feat: unenroll refunded android users daily (#4015) * feat: unenroll refunded android users daily Django management command to un-enroll refunded android users. This command will be run by Jenkins job daily. --- .../test_unenroll_refunded_android_users.py | 52 +++++++++++++++++++ .../unenroll_refunded_android_users.py | 27 ++++++++++ 2 files changed, 79 insertions(+) create mode 100644 ecommerce/core/management/commands/tests/test_unenroll_refunded_android_users.py create mode 100644 ecommerce/core/management/commands/unenroll_refunded_android_users.py 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) From b5a96f17f24e40d7e74016596944663c49d3b9ab Mon Sep 17 00:00:00 2001 From: jawad khan Date: Fri, 18 Aug 2023 00:12:38 +0500 Subject: [PATCH 2/4] feat: mail mobile team for a mobile course change in publisher (#4014) * feat: mail mobile team for a mobile course change in publisher This will fix any unknown change from publisher to a course having mobile seats. After this fix mobile team will see mail and adjust price of the course on playstore or appstore. In the longer run we want to replace this solution by changing the course price directly using mobile platform apis. LEARNER-9377 * fix: fixed coverage issue --- docs/additional_features/gate_ecommerce.rst | 3 + ecommerce/extensions/api/constatnts.py | 9 +++ ecommerce/extensions/api/serializers.py | 13 ++++ ecommerce/extensions/api/tests/test_utils.py | 62 +++++++++++++++++++ ecommerce/extensions/api/utils.py | 36 +++++++++++ ...rocessorconfiguration_mobile_team_email.py | 18 ++++++ ecommerce/extensions/iap/models.py | 6 ++ 7 files changed, 147 insertions(+) create mode 100644 ecommerce/extensions/api/constatnts.py create mode 100644 ecommerce/extensions/api/tests/test_utils.py create mode 100644 ecommerce/extensions/api/utils.py create mode 100644 ecommerce/extensions/iap/migrations/0006_iapprocessorconfiguration_mobile_team_email.py 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" From fa4ab4fa7aeb3440f4229e0354f4a2c687cb81bc Mon Sep 17 00:00:00 2001 From: Zubair Shakoor <57657330+zubairshakoorarbisoft@users.noreply.github.com> Date: Fri, 1 Sep 2023 18:55:46 +0500 Subject: [PATCH 3/4] fix: readthedocs file renamed (#4024) Updating readthedocs extension as yml going to be deprecated in september Issue: edx/edx-arch-experiments#418 --- .readthedocs.yml => .readthedocs.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .readthedocs.yml => .readthedocs.yaml (100%) diff --git a/.readthedocs.yml b/.readthedocs.yaml similarity index 100% rename from .readthedocs.yml rename to .readthedocs.yaml From f0e196fe4371f56a14969a1fb0d2fed79c39630a Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 7 Sep 2023 18:40:07 +0500 Subject: [PATCH 4/4] fix: Docker multi-arch images push for linux/amd64, linux/arm64 (#4006) * fix: docker multi-arch image push * fix: docker multi-arch images, code improvements --------- Co-authored-by: Salman Nawaz --- .github/workflows/docker-publish.yml | 30 ++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) 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