From b4a64a4c2e9736a056eb52e045089dc953b5def6 Mon Sep 17 00:00:00 2001 From: Jawad Khan Date: Tue, 16 Jul 2024 16:40:08 +0500 Subject: [PATCH] Fix: Sync mobile seats expiry and price with web seat LEARNER-10092 --- ..._mobile_seats_price_and_expiry_with_web.py | 54 ++++++++++ .../tests/test_batch_update_mobile_seats.py | 100 ++++-------------- ..._mobile_seats_price_and_expiry_with_web.py | 49 +++++++++ .../management/commands/tests/testutils.py | 90 ++++++++++++++++ 4 files changed, 211 insertions(+), 82 deletions(-) create mode 100644 ecommerce/extensions/iap/management/commands/sync_mobile_seats_price_and_expiry_with_web.py create mode 100644 ecommerce/extensions/iap/management/commands/tests/test_sync_mobile_seats_price_and_expiry_with_web.py create mode 100644 ecommerce/extensions/iap/management/commands/tests/testutils.py diff --git a/ecommerce/extensions/iap/management/commands/sync_mobile_seats_price_and_expiry_with_web.py b/ecommerce/extensions/iap/management/commands/sync_mobile_seats_price_and_expiry_with_web.py new file mode 100644 index 00000000000..0e32c7ba09a --- /dev/null +++ b/ecommerce/extensions/iap/management/commands/sync_mobile_seats_price_and_expiry_with_web.py @@ -0,0 +1,54 @@ +""" +This command sync mobile seat price and expiry with web seat price and expiry. +""" +import logging + +from django.core.management import BaseCommand + +from ecommerce.core.constants import SEAT_PRODUCT_CLASS_NAME +from ecommerce.courses.constants import CertificateType +from ecommerce.extensions.catalogue.models import Product + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Sync expiry and price of mobile seats with respective web seat. + """ + + help = 'Sync expiry and price of mobile seats with respective web seat.' + + def handle(self, *args, **options): + mobile_enabled_products = Product.objects.filter( + structure=Product.PARENT, + product_class__name=SEAT_PRODUCT_CLASS_NAME, + children__attribute_values__attribute__name="certificate_type", + children__attribute_values__value_text=CertificateType.VERIFIED, + children__stockrecords__isnull=False, + children__stockrecords__partner_sku__icontains="mobile", + ).distinct() + + for product in mobile_enabled_products: + mobile_seats = Product.objects.filter( + parent=product, + attribute_values__attribute__name="certificate_type", + attribute_values__value_text=CertificateType.VERIFIED, + stockrecords__partner_sku__icontains="mobile", + ) + + web_seat = Product.objects.filter( + parent=product, + attribute_values__attribute__name="certificate_type", + attribute_values__value_text=CertificateType.VERIFIED, + ).exclude(stockrecords__partner_sku__icontains="mobile").first() + + for mobile_seat in mobile_seats: + info = 'Syncing {} with {}'.format(mobile_seat.title, web_seat.title) + logger.info(info) + + stock_record = mobile_seat.stockrecords.all()[0] + stock_record.price_excl_tax = web_seat.stockrecords.all()[0].price_excl_tax + mobile_seat.expires = web_seat.expires + stock_record.save() + mobile_seat.save() diff --git a/ecommerce/extensions/iap/management/commands/tests/test_batch_update_mobile_seats.py b/ecommerce/extensions/iap/management/commands/tests/test_batch_update_mobile_seats.py index 72301133631..0a7fad7dee1 100644 --- a/ecommerce/extensions/iap/management/commands/tests/test_batch_update_mobile_seats.py +++ b/ecommerce/extensions/iap/management/commands/tests/test_batch_update_mobile_seats.py @@ -1,25 +1,17 @@ """Tests for the batch_update_mobile_seats command""" -from decimal import Decimal from unittest.mock import patch from django.core.management import call_command -from django.utils.timezone import now, timedelta from testfixtures import LogCapture from ecommerce.courses.models import Course -from ecommerce.courses.tests.factories import CourseFactory from ecommerce.extensions.catalogue.models import Product -from ecommerce.extensions.catalogue.tests.mixins import DiscoveryTestMixin from ecommerce.extensions.iap.management.commands.batch_update_mobile_seats import Command as mobile_seats_command +from ecommerce.extensions.iap.management.commands.tests.testutils import BaseIAPManagementCommandTests from ecommerce.extensions.iap.models import IAPProcessorConfiguration -from ecommerce.extensions.partner.models import StockRecord -from ecommerce.tests.testcases import TransactionTestCase -ANDROID_SKU_PREFIX = 'android' -IOS_SKU_PREFIX = 'ios' - -class BatchUpdateMobileSeatsTests(DiscoveryTestMixin, TransactionTestCase): +class BatchUpdateMobileSeatsTests(BaseIAPManagementCommandTests): """ Tests for the batch_update_mobile_seats command. """ @@ -27,62 +19,6 @@ def setUp(self): super().setUp() self.command = 'batch_update_mobile_seats' - def _create_course_and_seats(self, create_mobile_seats=False, expired_in_past=False, create_web_seat=True): - """ - Create the specified number of courses with audit and verified seats. Create mobile seats - if specified. - """ - course = CourseFactory(partner=self.partner) - course.create_or_update_seat('audit', False, 0) - if create_web_seat: - verified_seat = course.create_or_update_seat('verified', True, Decimal(10.0)) - verified_seat.title = ( - f'Seat in {course.name} with verified certificate (and ID verification)' - ) - expires = now() - timedelta(days=10) if expired_in_past else now() + timedelta(days=10) - verified_seat.expires = expires - verified_seat.save() - if create_mobile_seats: - self._create_mobile_seat_for_course(course, ANDROID_SKU_PREFIX) - self._create_mobile_seat_for_course(course, IOS_SKU_PREFIX) - - return course - - def _get_web_seat_for_course(self, course): - """ Get the default seat created for web for a course """ - return Product.objects.filter( - parent__isnull=False, - course=course, - attributes__name="id_verification_required", - parent__product_class__name="Seat" - ).first() - - def _create_mobile_seat_for_course(self, course, sku_prefix): - """ Create a mobile seat for a course given the sku_prefix """ - web_seat = self._get_web_seat_for_course(course) - web_stock_record = web_seat.stockrecords.first() - mobile_seat = Product.objects.create( - course=course, - parent=web_seat.parent, - structure=web_seat.structure, - expires=web_seat.expires, - is_public=web_seat.is_public, - title="{} {}".format(sku_prefix.capitalize(), web_seat.title.lower()) - ) - - mobile_seat.attr.certificate_type = web_seat.attr.certificate_type - mobile_seat.attr.course_key = web_seat.attr.course_key - mobile_seat.attr.id_verification_required = web_seat.attr.id_verification_required - mobile_seat.attr.save() - - StockRecord.objects.create( - partner=web_stock_record.partner, - product=mobile_seat, - partner_sku="mobile.{}.{}".format(sku_prefix.lower(), web_stock_record.partner_sku.lower()), - price_currency=web_stock_record.price_currency, - ) - return mobile_seat - @patch('ecommerce.extensions.iap.management.commands.batch_update_mobile_seats.Command._create_ios_product') @patch('ecommerce.extensions.iap.management.commands.batch_update_mobile_seats.get_course_detail') @patch('ecommerce.extensions.iap.management.commands.batch_update_mobile_seats.get_course_run_detail') @@ -91,8 +27,8 @@ def _create_mobile_seat_for_course(self, course, sku_prefix): def test_mobile_seat_for_new_course_run_created( self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail, mock_create_ios_product): """Test that the command creates mobile seats for new course run.""" - course_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=True, expired_in_past=True) - course_run_without_mobile_seat = self._create_course_and_seats() + course_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=True, expired_in_past=True) + course_run_without_mobile_seat = self.create_course_and_seats() course_run_return_value = {'course': course_with_mobile_seat.id} course_detail_return_value = {'course_run_keys': [course_run_without_mobile_seat.id]} @@ -119,8 +55,8 @@ def test_mobile_seat_for_new_course_run_created( def test_extra_seats_not_created( self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail, mock_create_ios_product): """Test the case where mobile seats are already created for course run.""" - course_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=True, expired_in_past=True) - course_run_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=True) + course_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=True, expired_in_past=True) + course_run_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=True) course_run_return_value = {'course': course_with_mobile_seat.id} course_detail_return_value = {'course_run_keys': [course_run_with_mobile_seat.id]} @@ -149,8 +85,8 @@ def test_no_mobile_products_returned( self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail, mock_create_ios_product, mock_create_child_products): """Test the case where mobile seats are already created for course run.""" - course_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=True, expired_in_past=True) - course_run_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=False) + course_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=True, expired_in_past=True) + course_run_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=False) course_run_return_value = {'course': course_with_mobile_seat.id} course_detail_return_value = {'course_run_keys': [course_run_with_mobile_seat.id]} @@ -178,8 +114,8 @@ def test_no_mobile_products_returned( def test_no_response_from_discovery_for_course_run_api( self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail, mock_create_ios_product): """Test that the command handles exceptions if no response returned from Discovery for course run API.""" - course_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=True, expired_in_past=True) - course_run_without_mobile_seat = self._create_course_and_seats() + course_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=True, expired_in_past=True) + course_run_without_mobile_seat = self.create_course_and_seats() course_run_return_value = None course_detail_return_value = {'course_run_keys': [course_run_without_mobile_seat.id]} @@ -209,8 +145,8 @@ def test_no_response_from_discovery_for_course_run_api( def test_no_response_from_discovery_for_course_detail_api( self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail, mock_create_ios_product): """Test that the command handles exceptions if no response returned from Discovery for course detail API.""" - course_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=True, expired_in_past=True) - course_run_without_mobile_seat = self._create_course_and_seats() + course_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=True, expired_in_past=True) + course_run_without_mobile_seat = self.create_course_and_seats() course_run_return_value = {'course': course_with_mobile_seat.id} logger_name = 'ecommerce.extensions.iap.management.commands.batch_update_mobile_seats' @@ -239,8 +175,8 @@ def test_no_response_from_discovery_for_course_detail_api( def test_error_in_creating_ios_products( self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail, mock_create_ios_product): """Test the case where mobile seats are already created for course run.""" - course_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=True, expired_in_past=True) - course_run_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=False) + course_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=True, expired_in_past=True) + course_run_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=False) course_run_return_value = {'course': course_with_mobile_seat.id} course_detail_return_value = {'course_run_keys': [course_run_with_mobile_seat.id]} @@ -266,7 +202,7 @@ def test_error_in_creating_ios_products( @patch.object(mobile_seats_command, '_send_email_about_expired_courses') def test_command_arguments_are_processed( self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail, mock_create_ios_product): - course_with_mobile_seat = self._create_course_and_seats(create_mobile_seats=True, expired_in_past=True) + course_with_mobile_seat = self.create_course_and_seats(create_mobile_seats=True, expired_in_past=True) mock_email.return_value = None mock_publish_to_lms.return_value = None mock_course_run.return_value = {'course': course_with_mobile_seat.id} @@ -289,7 +225,7 @@ def test_send_mail_to_mobile_team(self, mock_publish_to_lms, mock_course_run, mo iap_configs = IAPProcessorConfiguration.get_solo() iap_configs.mobile_team_email = mock_mobile_team_mail iap_configs.save() - course = self._create_course_and_seats(create_mobile_seats=True, expired_in_past=True) + course = self.create_course_and_seats(create_mobile_seats=True, expired_in_past=True) mock_publish_to_lms.return_value = None mock_course_run.return_value = {'course': course.id} @@ -328,7 +264,7 @@ def test_send_mail_to_mobile_team_with_no_email(self, mock_publish_to_lms, mock_ iap_configs = IAPProcessorConfiguration.get_solo() iap_configs.mobile_team_email = "" iap_configs.save() - course = self._create_course_and_seats(create_mobile_seats=True, expired_in_past=True) + course = self.create_course_and_seats(create_mobile_seats=True, expired_in_past=True) mock_publish_to_lms.return_value = None mock_course_run.return_value = {'course': course.id} @@ -358,7 +294,7 @@ def test_no_expired_courses(self): iap_configs = IAPProcessorConfiguration.get_solo() iap_configs.mobile_team_email = mock_mobile_team_mail iap_configs.save() - self._create_course_and_seats(create_mobile_seats=True, expired_in_past=False) + self.create_course_and_seats(create_mobile_seats=True, expired_in_past=False) expected_body = "\nExpired Courses:\n" expected_body += "\n\nNew course runs processed:\n" diff --git a/ecommerce/extensions/iap/management/commands/tests/test_sync_mobile_seats_price_and_expiry_with_web.py b/ecommerce/extensions/iap/management/commands/tests/test_sync_mobile_seats_price_and_expiry_with_web.py new file mode 100644 index 00000000000..b04f0dfb481 --- /dev/null +++ b/ecommerce/extensions/iap/management/commands/tests/test_sync_mobile_seats_price_and_expiry_with_web.py @@ -0,0 +1,49 @@ +"""Tests for the sync_mobile_seats_price_and_expiry_with_web command""" +from datetime import datetime + +from django.core.management import call_command + +from ecommerce.extensions.iap.management.commands.tests.testutils import BaseIAPManagementCommandTests + + +class SyncMobileSeatsTests(BaseIAPManagementCommandTests): + """ + Tests for the sync_mobile_seats_price_and_expiry_with_web command. + """ + def setUp(self): + super().setUp() + self.command = 'sync_mobile_seats_price_and_expiry_with_web' + self.course_with_all_seats = self.create_course_and_seats(create_mobile_seats=True, create_web_seat=True) + self.course_with_web_seat_only = self.create_course_and_seats(create_mobile_seats=False, create_web_seat=True) + self.course_with_audit_seat = self.create_course_and_seats(create_mobile_seats=False, create_web_seat=False) + self.course_with_unsync_seats = self.create_course_and_seats(create_mobile_seats=True, create_web_seat=True) + self.course_with_unsync_seats2 = self.create_course_and_seats(create_mobile_seats=True, create_web_seat=True) + mobile_seats = self.get_mobile_seats_for_course(self.course_with_unsync_seats) + mobile_seats = list(mobile_seats) + list(self.get_mobile_seats_for_course(self.course_with_unsync_seats2)) + + for mobile_seat in mobile_seats: + mobile_seat.expiry = datetime.now() + mobile_seat.save() + + stockrecord = mobile_seat.stockrecords.all()[0] + stockrecord.price_excl_tax += 10 + stockrecord.save() + + def test_sync_mobile_seat(self): + web_seat = self.get_web_seat_for_course(self.course_with_unsync_seats) + web_seat_expiry = web_seat.expires + web_seat_price = web_seat.stockrecords.all()[0].price_excl_tax + + web_seat = self.get_web_seat_for_course(self.course_with_unsync_seats2) + web_seat_expiry2 = web_seat.expires + web_seat_price2 = web_seat.stockrecords.all()[0].price_excl_tax + + call_command(self.command) + self.verify_course_seats_update(self.course_with_unsync_seats, web_seat_expiry, web_seat_price) + self.verify_course_seats_update(self.course_with_unsync_seats2, web_seat_expiry2, web_seat_price2) + + def verify_course_seats_update(self, course, expiry, price): + mobile_seats = self.get_mobile_seats_for_course(course) + for mobile_seat in mobile_seats: + assert mobile_seat.expires == expiry + assert mobile_seat.stockrecords.all()[0].price_excl_tax == price diff --git a/ecommerce/extensions/iap/management/commands/tests/testutils.py b/ecommerce/extensions/iap/management/commands/tests/testutils.py new file mode 100644 index 00000000000..7337ef663c6 --- /dev/null +++ b/ecommerce/extensions/iap/management/commands/tests/testutils.py @@ -0,0 +1,90 @@ +""" Test utilities for iap management commands """ +from decimal import Decimal + +from django.utils.timezone import now, timedelta + +from ecommerce.courses.constants import CertificateType +from ecommerce.courses.tests.factories import CourseFactory +from ecommerce.extensions.catalogue.models import Product +from ecommerce.extensions.catalogue.tests.mixins import DiscoveryTestMixin +from ecommerce.extensions.partner.models import StockRecord +from ecommerce.tests.testcases import TransactionTestCase + +ANDROID_SKU_PREFIX = 'android' +IOS_SKU_PREFIX = 'ios' + + +class BaseIAPManagementCommandTests(DiscoveryTestMixin, TransactionTestCase): + """ + Base class for iap management commands. + """ + def create_course_and_seats(self, create_mobile_seats=False, expired_in_past=False, create_web_seat=True): + """ + Create the specified number of courses with audit and verified seats. Create mobile seats + if specified. + """ + course = CourseFactory(partner=self.partner) + course.create_or_update_seat('audit', False, 0) + if create_web_seat: + verified_seat = course.create_or_update_seat('verified', True, Decimal(10.0)) + verified_seat.title = ( + f'Seat in {course.name} with verified certificate (and ID verification)' + ) + expires = now() - timedelta(days=10) if expired_in_past else now() + timedelta(days=10) + verified_seat.expires = expires + verified_seat.save() + if create_mobile_seats: + self.create_mobile_seat_for_course(course, ANDROID_SKU_PREFIX) + self.create_mobile_seat_for_course(course, IOS_SKU_PREFIX) + + return course + + def create_mobile_seat_for_course(self, course, sku_prefix): + """ Create a mobile seat for a course given the sku_prefix """ + web_seat = self.get_web_seat_for_course(course) + web_stock_record = web_seat.stockrecords.first() + mobile_seat = Product.objects.create( + course=course, + parent=web_seat.parent, + structure=web_seat.structure, + expires=web_seat.expires, + is_public=web_seat.is_public, + title="{} {}".format(sku_prefix.capitalize(), web_seat.title.lower()) + ) + + mobile_seat.attr.certificate_type = web_seat.attr.certificate_type + mobile_seat.attr.course_key = web_seat.attr.course_key + mobile_seat.attr.id_verification_required = web_seat.attr.id_verification_required + mobile_seat.attr.save() + + StockRecord.objects.create( + partner=web_stock_record.partner, + product=mobile_seat, + partner_sku="mobile.{}.{}".format(sku_prefix.lower(), web_stock_record.partner_sku.lower()), + price_currency=web_stock_record.price_currency, + price_excl_tax=100 + ) + return mobile_seat + + @staticmethod + def get_web_seat_for_course(course): + """ Get the default seat created for web for a course """ + return Product.objects.filter( + parent__isnull=False, + course=course, + attributes__name="id_verification_required", + parent__product_class__name="Seat" + ).exclude(stockrecords__partner_sku__icontains="mobile").first() + + @staticmethod + def get_mobile_seats_for_course(course): + """ Get mobile seats created for a course """ + return Product.objects.filter( + parent__isnull=False, + course=course, + parent__product_class__name="Seat", + attribute_values__attribute__name="certificate_type", + attribute_values__value_text=CertificateType.VERIFIED, + stockrecords__isnull=False, + stockrecords__partner_sku__icontains="mobile", + )