From 6e601e507ab4a9f766475202a79032f593030dc1 Mon Sep 17 00:00:00 2001 From: Moeez Zahid Date: Mon, 13 Nov 2023 08:08:33 +0500 Subject: [PATCH] feat: Mgmt Command to create mobile seats for new course runs (#4046) --- ecommerce/extensions/iap/constants.py | 2 + .../extensions/iap/management/__init__.py | 0 .../iap/management/commands/__init__.py | 0 .../commands/batch_update_mobile_seats.py | 191 ++++++++++++ .../iap/management/commands/tests/__init__.py | 0 .../tests/test_batch_update_mobile_seats.py | 272 ++++++++++++++++++ 6 files changed, 465 insertions(+) create mode 100644 ecommerce/extensions/iap/constants.py create mode 100644 ecommerce/extensions/iap/management/__init__.py create mode 100644 ecommerce/extensions/iap/management/commands/__init__.py create mode 100644 ecommerce/extensions/iap/management/commands/batch_update_mobile_seats.py create mode 100644 ecommerce/extensions/iap/management/commands/tests/__init__.py create mode 100644 ecommerce/extensions/iap/management/commands/tests/test_batch_update_mobile_seats.py diff --git a/ecommerce/extensions/iap/constants.py b/ecommerce/extensions/iap/constants.py new file mode 100644 index 00000000000..0c28643368d --- /dev/null +++ b/ecommerce/extensions/iap/constants.py @@ -0,0 +1,2 @@ +ANDROID_SKU_PREFIX = 'android' +IOS_SKU_PREFIX = 'ios' diff --git a/ecommerce/extensions/iap/management/__init__.py b/ecommerce/extensions/iap/management/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ecommerce/extensions/iap/management/commands/__init__.py b/ecommerce/extensions/iap/management/commands/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ecommerce/extensions/iap/management/commands/batch_update_mobile_seats.py b/ecommerce/extensions/iap/management/commands/batch_update_mobile_seats.py new file mode 100644 index 00000000000..721afafb2b3 --- /dev/null +++ b/ecommerce/extensions/iap/management/commands/batch_update_mobile_seats.py @@ -0,0 +1,191 @@ +""" +This command fetches new course runs for mobile supported courses and creates seats/SKUS for them. +""" +import logging +import time + +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.management import BaseCommand +from django.db.models import Q +from django.utils.timezone import now, timedelta +from oscar.core.loading import get_class + +from ecommerce.core.constants import SEAT_PRODUCT_CLASS_NAME +from ecommerce.courses.constants import CertificateType +from ecommerce.courses.models import Course +from ecommerce.courses.utils import get_course_detail, get_course_run_detail +from ecommerce.extensions.catalogue.models import Product +from ecommerce.extensions.iap.constants import ANDROID_SKU_PREFIX, IOS_SKU_PREFIX +from ecommerce.extensions.iap.models import IAPProcessorConfiguration +from ecommerce.extensions.partner.models import StockRecord + +Dispatcher = get_class('communication.utils', 'Dispatcher') +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Create Seats/SKUS for new course runs of courses that have mobile payments enabled and + have expired. + """ + + help = 'Create Seats/SKUS for all new course runs of mobile supported courses.' + + def add_arguments(self, parser): + parser.add_argument( + '--batch-size', + type=int, + default=1000, + help='Maximum number of seats to update in one batch') + parser.add_argument( + '--sleep-time', + type=int, + default=10, + help='Sleep time in seconds between update of batches') + + def handle(self, *args, **options): + batch_size = options['batch_size'] + sleep_time = options['sleep_time'] + default_site = Site.objects.filter(id=settings.SITE_ID).first() + batch_counter = 0 + + # Fetch products which expired in the last month and had mobile skus. + expired_products = Product.objects.filter( + attribute_values__attribute__name="certificate_type", + attribute_values__value_text=CertificateType.VERIFIED, + parent__product_class__name=SEAT_PRODUCT_CLASS_NAME, + stockrecords__partner_sku__icontains="mobile", + expires__lt=now(), + expires__gt=now() - timedelta(days=30) + ) + + # Fetch courses for these products + expired_courses = Course.objects.filter(products__in=expired_products).distinct() + if expired_courses: + self._send_email_about_expired_courses(expired_courses=expired_courses) + for expired_course in expired_courses: + # Get parent course key from discovery for the current course run + course_run_detail_response = get_course_run_detail(default_site, expired_course.id) + try: + parent_course_key = course_run_detail_response.get('course') + except AttributeError: + message = "Error while fetching parent course for {} from discovery".format(expired_course.id) + logger.ERROR(message) + continue # pragma: no cover + + # Get all course run keys for parent course from discovery. Then filter those + # courses/course runs on Ecommerce using Course.verification_deadline and + # Product.expires to determine products to create course runs for. + parent_course = get_course_detail(default_site, parent_course_key) + try: + all_course_run_keys = parent_course.get('course_run_keys') + except AttributeError: + message = "Error while fetching course runs for {} from discovery".format(parent_course_key) + logger.ERROR(message) + continue # pragma: no cover + + all_course_runs = Course.objects.filter(id__in=all_course_run_keys) + parent_products = self._get_parent_products_to_create_mobile_skus_for(all_course_runs) + for parent_product in parent_products: + self._create_child_products_for_mobile(parent_product) + + expired_course.publish_to_lms() + batch_counter += 1 + if batch_counter >= batch_size: + time.sleep(sleep_time) + batch_counter = 0 + + def _get_parent_products_to_create_mobile_skus_for(self, courses): + """ + From courses, filter the products that: + - Have expiry date in the future + - Have verified attribute set + - Have web skus created for them + - Do not have mobile skus created for them yet + """ + products_to_create_mobile_skus_for = Product.objects.filter( + ~Q(children__stockrecords__partner_sku__icontains="mobile"), + structure=Product.PARENT, + children__stockrecords__isnull=False, + children__attribute_values__attribute__name="certificate_type", + children__attribute_values__value_text=CertificateType.VERIFIED, + product_class__name=SEAT_PRODUCT_CLASS_NAME, + children__expires__gt=now(), + course__in=courses, + ) + return products_to_create_mobile_skus_for + + def _create_child_products_for_mobile(self, product): + """ + Create child products/seats for IOS and Android. + Child product is also called a variant in the UI + """ + existing_web_seat = Product.objects.filter( + ~Q(stockrecords__partner_sku__icontains="mobile"), + parent=product, + attribute_values__attribute__name="certificate_type", + attribute_values__value_text=CertificateType.VERIFIED, + parent__product_class__name=SEAT_PRODUCT_CLASS_NAME, + ).first() + if existing_web_seat: + self._create_mobile_seat(ANDROID_SKU_PREFIX, existing_web_seat) + self._create_mobile_seat(IOS_SKU_PREFIX, existing_web_seat) + + def _create_mobile_seat(self, sku_prefix, existing_web_seat): + """ + Create a mobile seat, attributes and stock records matching the given existing_web_seat + in the same Parent Product. + """ + new_mobile_seat, _ = Product.objects.get_or_create( + title="{} {}".format(sku_prefix.capitalize(), existing_web_seat.title.lower()), + course=existing_web_seat.course, + parent=existing_web_seat.parent, + product_class=existing_web_seat.product_class, + structure=existing_web_seat.structure + ) + new_mobile_seat.expires = existing_web_seat.expires + new_mobile_seat.is_public = existing_web_seat.is_public + new_mobile_seat.save() + + # Set seat attributes + new_mobile_seat.attr.certificate_type = existing_web_seat.attr.certificate_type + new_mobile_seat.attr.course_key = existing_web_seat.attr.course_key + new_mobile_seat.attr.id_verification_required = existing_web_seat.attr.id_verification_required + new_mobile_seat.attr.save() + + # Create stock records + existing_stock_record = existing_web_seat.stockrecords.first() + mobile_stock_record, created = StockRecord.objects.get_or_create( + product=new_mobile_seat, + partner=existing_stock_record.partner + ) + if created: + partner_sku = 'mobile.{}.{}'.format(sku_prefix.lower(), existing_stock_record.partner_sku.lower()) + mobile_stock_record.partner_sku = partner_sku + mobile_stock_record.price_currency = existing_stock_record.price_currency + mobile_stock_record.price_excl_tax = existing_stock_record.price_excl_tax + mobile_stock_record.price_retail = existing_stock_record.price_retail + mobile_stock_record.cost_price = existing_stock_record.cost_price + mobile_stock_record.save() + + def _send_email_about_expired_courses(self, expired_courses): + """ + Send email to IAPProcessorConfiguration.mobile_team_email with SKUS for + expired mobile courses. + """ + recipient = IAPProcessorConfiguration.get_solo().mobile_team_email + if not recipient: + msg = "Couldn't mail mobile team for expired courses with SKUS. " \ + "No email was specified for mobile team in configurations" + logger.info(msg) + return + + expired_courses_keys = list(expired_courses.values_list('id', flat=True)) + messages = { + 'subject': 'Expired Courses with mobile SKUS alert', + 'body': "\n".join(expired_courses_keys), + 'html': None, + } + Dispatcher().dispatch_direct_messages(recipient, messages) + logger.info("Sent Expired Courses alert email to mobile team.") diff --git a/ecommerce/extensions/iap/management/commands/tests/__init__.py b/ecommerce/extensions/iap/management/commands/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d 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 new file mode 100644 index 00000000000..27916935e70 --- /dev/null +++ b/ecommerce/extensions/iap/management/commands/tests/test_batch_update_mobile_seats.py @@ -0,0 +1,272 @@ +"""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.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): + """ + Tests for the batch_update_mobile_seats command. + """ + 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 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) + 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.get_course_detail') + @patch('ecommerce.extensions.iap.management.commands.batch_update_mobile_seats.get_course_run_detail') + @patch.object(Course, 'publish_to_lms') + @patch.object(mobile_seats_command, '_send_email_about_expired_courses') + def test_mobile_seat_for_new_course_run_created( + self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail): + """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_run_return_value = {'course': course_with_mobile_seat.id} + course_detail_return_value = {'course_run_keys': [course_run_without_mobile_seat.id]} + + mock_email.return_value = None + mock_publish_to_lms.return_value = None + mock_course_run.return_value = course_run_return_value + mock_course_detail.return_value = course_detail_return_value + + call_command(self.command) + actual_mobile_seats = Product.objects.filter( + course=course_run_without_mobile_seat, + stockrecords__partner_sku__icontains='mobile' + ) + expected_mobile_seats_count = 2 + self.assertTrue(actual_mobile_seats.exists()) + self.assertEqual(actual_mobile_seats.count(), expected_mobile_seats_count) + + @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') + @patch.object(Course, 'publish_to_lms') + @patch.object(mobile_seats_command, '_send_email_about_expired_courses') + def test_extra_seats_not_created( + self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail): + """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_run_return_value = {'course': course_with_mobile_seat.id} + course_detail_return_value = {'course_run_keys': [course_run_with_mobile_seat.id]} + + mock_email.return_value = None + mock_publish_to_lms.return_value = None + mock_course_run.return_value = course_run_return_value + mock_course_detail.return_value = course_detail_return_value + + call_command(self.command) + actual_mobile_seats = Product.objects.filter( + course=course_run_with_mobile_seat, + stockrecords__partner_sku__icontains='mobile' + ) + expected_mobile_seats_count = 2 + self.assertTrue(actual_mobile_seats.exists()) + self.assertEqual(actual_mobile_seats.count(), expected_mobile_seats_count) + + @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') + @patch.object(Course, 'publish_to_lms') + @patch.object(mobile_seats_command, '_send_email_about_expired_courses') + def test_no_response_from_discovery_for_course_run_api( + self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail): + """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_run_return_value = None + course_detail_return_value = {'course_run_keys': [course_run_without_mobile_seat.id]} + + logger_name = 'ecommerce.extensions.iap.management.commands.batch_update_mobile_seats' + mock_email.return_value = None + mock_publish_to_lms.return_value = None + mock_course_run.return_value = course_run_return_value + mock_course_detail.return_value = course_detail_return_value + + with self.assertRaises(AttributeError), \ + LogCapture(logger_name) as logger: + call_command(self.command) + msg = "Error while fetching parent course for {} from discovery".format(course_with_mobile_seat.id) + logger.check_present(logger_name, 'ERROR', msg) + + actual_mobile_seats = Product.objects.filter( + course=course_run_without_mobile_seat, + stockrecords__partner_sku__icontains='mobile' + ) + self.assertFalse(actual_mobile_seats.exists()) + + @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') + @patch.object(Course, 'publish_to_lms') + @patch.object(mobile_seats_command, '_send_email_about_expired_courses') + def test_no_response_from_discovery_for_course_detail_api( + self, mock_email, mock_publish_to_lms, mock_course_run, mock_course_detail): + """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_run_return_value = {'course': course_with_mobile_seat.id} + + logger_name = 'ecommerce.extensions.iap.management.commands.batch_update_mobile_seats' + mock_email.return_value = None + mock_publish_to_lms.return_value = None + mock_course_run.return_value = course_run_return_value + mock_course_detail.return_value = None + + with self.assertRaises(AttributeError), \ + LogCapture(logger_name) as logger: + call_command(self.command) + msg = "Error while fetching course runs for {} from discovery".format(course_with_mobile_seat.id) + logger.check_present(logger_name, 'ERROR', msg) + + actual_mobile_seats = Product.objects.filter( + course=course_run_without_mobile_seat, + stockrecords__partner_sku__icontains='mobile' + ) + self.assertFalse(actual_mobile_seats.exists()) + + @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') + @patch.object(Course, 'publish_to_lms') + @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): + 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} + mock_course_detail.return_value = {'course_run_keys': []} + + call_command(self.command, batch_size=1, sleep_time=1) + assert mock_email.call_count == 1 + + @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') + @patch.object(Course, 'publish_to_lms') + def test_send_mail_to_mobile_team(self, mock_publish_to_lms, mock_course_run, mock_course_detail): + logger_name = 'ecommerce.extensions.iap.management.commands.batch_update_mobile_seats' + email_sender = 'ecommerce.extensions.communication.utils.Dispatcher.dispatch_direct_messages' + mock_mobile_team_mail = 'abc@example.com' + 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) + + mock_publish_to_lms.return_value = None + mock_course_run.return_value = {'course': course.id} + mock_course_detail.return_value = {'course_run_keys': []} + mock_email_body = { + 'subject': 'Expired Courses with mobile SKUS alert', + 'body': '{}'.format(course.id), + 'html': None, + } + + with LogCapture(logger_name) as logger,\ + patch(email_sender) as mock_send_email: + call_command(self.command) + logger.check_present( + ( + logger_name, + 'INFO', + 'Sent Expired Courses alert email to mobile team.' + ) + ) + assert mock_send_email.call_count == 1 + mock_send_email.assert_called_with(mock_mobile_team_mail, mock_email_body) + + @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') + @patch.object(Course, 'publish_to_lms') + def test_send_mail_to_mobile_team_with_no_email(self, mock_publish_to_lms, mock_course_run, mock_course_detail): + logger_name = 'ecommerce.extensions.iap.management.commands.batch_update_mobile_seats' + email_sender = 'ecommerce.extensions.communication.utils.Dispatcher.dispatch_direct_messages' + 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) + + mock_publish_to_lms.return_value = None + mock_course_run.return_value = {'course': course.id} + mock_course_detail.return_value = {'course_run_keys': []} + + with LogCapture(logger_name) as logger, \ + patch(email_sender) as mock_send_email: + call_command(self.command) + msg = "Couldn't mail mobile team for expired courses with SKUS. " \ + "No email was specified for mobile team in configurations" + logger.check_present( + ( + logger_name, + 'INFO', + msg + ) + ) + assert mock_send_email.call_count == 0