diff --git a/ecommerce/extensions/iap/api/v1/constants.py b/ecommerce/extensions/iap/api/v1/constants.py index 7bfcd9ed8e8..03b8f8dc267 100644 --- a/ecommerce/extensions/iap/api/v1/constants.py +++ b/ecommerce/extensions/iap/api/v1/constants.py @@ -13,6 +13,7 @@ ERROR_REFUND_NOT_COMPLETED = "Could not complete refund for user [%s] in course [%s] by processor [%s]" ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND = "Could not find any transaction to refund for [%s] by processor [%s]" ERROR_DURING_POST_ORDER_OP = "An error occurred during post order operations." +FOUND_MULTIPLE_PRODUCTS_ERROR = "Found unexpected number of products for course [%s]" GOOGLE_PUBLISHER_API_SCOPE = "https://www.googleapis.com/auth/androidpublisher" IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE = "Ignoring notification from apple since we are only expecting" \ " refund notifications" @@ -35,9 +36,12 @@ LOGGER_PAYMENT_FAILED_FOR_BASKET = "Attempts to handle payment for basket [%s] failed with error [%s]." LOGGER_REFUND_SUCCESSFUL = "Refund successful. OrderId: [%s] Processor: [%s] " LOGGER_STARTING_PAYMENT_FLOW = "Starting payment flow for user [%s] for products [%s]." +MISSING_PRODUCT_ERROR = "Couldn't find parent product for course [%s]" NO_PRODUCT_AVAILABLE = "No product is available to buy." PRODUCTS_DO_NOT_EXIST = "Products with SKU(s) [{skus}] do not exist." PRODUCT_IS_NOT_AVAILABLE = "Product [%s] is not available to buy." RECEIVED_NOTIFICATION_FROM_APPLE = "Received notification from apple with notification type [%s]" SEGMENT_MOBILE_BASKET_ADD = "Mobile Basket Add Items View Called" SEGMENT_MOBILE_PURCHASE_VIEW = "Mobile Course Purchase View Called" +SKUS_CREATION_ERROR = "There was an error while creating mobile skus for course [%s]" +SKUS_CREATION_FAILURE = "Couldn't create mobile skus for course [%s]" diff --git a/ecommerce/extensions/iap/api/v1/tests/test_views.py b/ecommerce/extensions/iap/api/v1/tests/test_views.py index 16ce322faa0..c9f0d306c7f 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_views.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_views.py @@ -1041,3 +1041,66 @@ def test_unknown_error_in_parsing_or_refund(self): "Test Exception" ), ) + + +class TestMobileSkusCreationView(TestCase): + """ Tests for MobileSkusCreationView API view. """ + path = reverse('iap:create-mobile-skus') + + def setUp(self): + super(TestMobileSkusCreationView, self).setUp() + self.user = self.create_user(is_staff=True) + self.client.login(username=self.user.username, password=self.password) + + self.course = CourseFactory(partner=self.partner) + future_expiry = pytz.utc.localize(datetime.datetime.max) + self.product = self.course.create_or_update_seat('verified', False, 50, expires=future_expiry) + + self.logger_name = 'ecommerce.extensions.iap.api.v1.views' + + def test_failure_for_not_admin_user(self): + """ Verify the endpoint requires admin authentication. """ + self.client.logout() + not_admin_user = self.create_user() + self.client.login(username=not_admin_user.username, password=not_admin_user.password) + response = self.client.post(self.path, data={}) + self.assertEqual(response.status_code, 401) + + def test_empty_list(self): + """ Verify the endpoint returns HTTP 200 if we send empty list.""" + with LogCapture(self.logger_name) as logger: + response = self.client.post(self.path, data={'courses': []}) + logger.check() + self.assertEqual(response.status_code, 200) + result = json.loads(response.content) + expected_result = { + "new_mobile_skus": {}, + "failed_course_ids": [], + "missing_course_runs": [] + } + self.assertEqual(result, expected_result) + + def test_missing_and_new_skus_in_course(self): + """ Verify the view differentiate between a correct and non-existent course id """ + with LogCapture(self.logger_name) as logger: + post_data = {"courses": ["course:wrong-id", self.course.id]} + response = self.client.post(self.path, data=post_data, content_type="application/json") + + logger.check() + self.assertEqual(response.status_code, 200) + result = json.loads(response.content) + stock_record = StockRecord.objects.get(product=self.product) + + expected_result = { + "new_mobile_skus": { + self.course.id: + [ + "mobile.android.{}".format(stock_record.partner_sku.lower()), + "mobile.ios.{}".format(stock_record.partner_sku.lower()), + ] + }, + "failed_course_ids": [], + "missing_course_runs": ['course:wrong-id'] + } + + self.assertEqual(result, expected_result) diff --git a/ecommerce/extensions/iap/api/v1/urls.py b/ecommerce/extensions/iap/api/v1/urls.py index f9a142ef440..cc9ae3b2c9a 100644 --- a/ecommerce/extensions/iap/api/v1/urls.py +++ b/ecommerce/extensions/iap/api/v1/urls.py @@ -5,7 +5,8 @@ IOSRefundView, MobileBasketAddItemsView, MobileCheckoutView, - MobileCoursePurchaseExecutionView + MobileCoursePurchaseExecutionView, + MobileSkusCreationView ) urlpatterns = [ @@ -14,4 +15,5 @@ url(r'^execute/$', MobileCoursePurchaseExecutionView.as_view(), name='iap-execute'), url(r'^android/refund/$', AndroidRefundView.as_view(), name='android-refund'), url(r'^ios/refund/$', IOSRefundView.as_view(), name='ios-refund'), + url(r'^create-mobile-skus/$', MobileSkusCreationView.as_view(), name='create-mobile-skus'), ] diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index 647b736d702..1d939d0c36e 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -5,24 +5,28 @@ import app_store_notifications_v2_validator as asn2 import httplib2 from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.http import JsonResponse from django.utils.decorators import method_decorator from django.utils.html import escape +from django.utils.timezone import now from django.utils.translation import ugettext as _ from edx_django_utils import monitoring as monitoring_utils from edx_rest_framework_extensions.permissions import LoginRedirectIfUnauthenticated from googleapiclient.discovery import build from oauth2client.service_account import ServiceAccountCredentials +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from oscar.apps.basket.views import * # pylint: disable=wildcard-import, unused-wildcard-import from oscar.apps.payment.exceptions import GatewayError, PaymentError from oscar.core.loading import get_class, get_model from rest_framework import status -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from ecommerce.core.constants import SEAT_PRODUCT_CLASS_NAME +from ecommerce.courses.constants import CertificateType +from ecommerce.courses.models import Course from ecommerce.extensions.analytics.utils import track_segment_event from ecommerce.extensions.api.v2.views.checkout import CheckoutView from ecommerce.extensions.basket.exceptions import BadRequestException @@ -46,6 +50,7 @@ ERROR_ORDER_NOT_FOUND_FOR_REFUND, ERROR_REFUND_NOT_COMPLETED, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, + FOUND_MULTIPLE_PRODUCTS_ERROR, GOOGLE_PUBLISHER_API_SCOPE, IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE, LOGGER_BASKET_ALREADY_PURCHASED, @@ -63,12 +68,15 @@ LOGGER_PAYMENT_FAILED_FOR_BASKET, LOGGER_REFUND_SUCCESSFUL, LOGGER_STARTING_PAYMENT_FLOW, + MISSING_PRODUCT_ERROR, NO_PRODUCT_AVAILABLE, PRODUCT_IS_NOT_AVAILABLE, PRODUCTS_DO_NOT_EXIST, RECEIVED_NOTIFICATION_FROM_APPLE, SEGMENT_MOBILE_BASKET_ADD, - SEGMENT_MOBILE_PURCHASE_VIEW + SEGMENT_MOBILE_PURCHASE_VIEW, + SKUS_CREATION_ERROR, + SKUS_CREATION_FAILURE ) from ecommerce.extensions.iap.api.v1.exceptions import RefundCompletionException from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer @@ -76,6 +84,7 @@ from ecommerce.extensions.iap.models import IAPProcessorConfiguration from ecommerce.extensions.iap.processors.android_iap import AndroidIAP from ecommerce.extensions.iap.processors.ios_iap import IOSIAP +from ecommerce.extensions.iap.utils import create_child_products_for_mobile from ecommerce.extensions.order.exceptions import AlreadyPlacedOrderException from ecommerce.extensions.partner.shortcuts import get_partner_for_site from ecommerce.extensions.payment.exceptions import RedundantPaymentNotificationError @@ -366,3 +375,67 @@ def post(self, request): status_code = status.HTTP_200_OK if is_refunded else status.HTTP_500_INTERNAL_SERVER_ERROR return Response(status=status_code) + + +class MobileSkusCreationView(APIView): + + permission_classes = (IsAuthenticated, IsAdminUser,) + + def post(self, request): + missing_course_runs = [] + failed_course_runs = [] + created_skus = {} + + course_run_keys = request.data.get('courses', []) + for course_run_key in course_run_keys: + try: + course_run = CourseKey.from_string(course_run_key) + except InvalidKeyError: + # An `InvalidKeyError` is thrown because this course key is not a course run key + missing_course_runs.append(course_run_key) + continue + + parent_product = Product.objects.filter( + 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=course_run, + ) + + if not parent_product.exists(): + failed_course_runs.append(course_run_key) + logger.error(MISSING_PRODUCT_ERROR, course_run_key) + continue + + if parent_product.count() > 1: + # We are handling a use case we are not familiar with. + # We should return the course id to add mobile skus manually for this course run. + failed_course_runs.append(course_run_key) + logger.error(FOUND_MULTIPLE_PRODUCTS_ERROR, course_run_key) + continue + + try: + mobile_products = create_child_products_for_mobile(parent_product[0]) + except Exception: # pylint: disable=broad-except + failed_course_runs.append(course_run_key) + logger.error(SKUS_CREATION_ERROR, course_run_key) + continue + + if not mobile_products: + failed_course_runs.append(course_run_key) + logger.error(SKUS_CREATION_FAILURE, course_run_key) + continue + + created_skus[course_run_key] = [mobile_products[0].partner_sku, mobile_products[1].partner_sku] + course = Course.objects.get(id=course_run) + course.publish_to_lms() + + result = { + 'new_mobile_skus': created_skus, + 'failed_course_ids': failed_course_runs, + 'missing_course_runs': missing_course_runs + } + return JsonResponse(result, status=status.HTTP_200_OK) diff --git a/ecommerce/extensions/iap/constants.py b/ecommerce/extensions/iap/constants.py index 0c28643368d..1efcd8d319d 100644 --- a/ecommerce/extensions/iap/constants.py +++ b/ecommerce/extensions/iap/constants.py @@ -1,2 +1,3 @@ ANDROID_SKU_PREFIX = 'android' IOS_SKU_PREFIX = 'ios' +MISSING_WEB_SEAT_ERROR = "Couldn't find existing web seat for course [%s]" diff --git a/ecommerce/extensions/iap/management/commands/batch_update_mobile_seats.py b/ecommerce/extensions/iap/management/commands/batch_update_mobile_seats.py index 721afafb2b3..a4725744800 100644 --- a/ecommerce/extensions/iap/management/commands/batch_update_mobile_seats.py +++ b/ecommerce/extensions/iap/management/commands/batch_update_mobile_seats.py @@ -16,9 +16,8 @@ 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 +from ecommerce.extensions.iap.utils import create_child_products_for_mobile Dispatcher = get_class('communication.utils', 'Dispatcher') logger = logging.getLogger(__name__) @@ -88,7 +87,7 @@ def handle(self, *args, **options): 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) + create_child_products_for_mobile(parent_product) expired_course.publish_to_lms() batch_counter += 1 @@ -116,59 +115,6 @@ def _get_parent_products_to_create_mobile_skus_for(self, 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 diff --git a/ecommerce/extensions/iap/utils.py b/ecommerce/extensions/iap/utils.py new file mode 100644 index 00000000000..373287439c6 --- /dev/null +++ b/ecommerce/extensions/iap/utils.py @@ -0,0 +1,74 @@ +import logging + +from django.db.models import Q +from oscar.core.loading import get_class + +from ecommerce.core.constants import SEAT_PRODUCT_CLASS_NAME +from ecommerce.courses.constants import CertificateType +from ecommerce.extensions.catalogue.models import Product +from ecommerce.extensions.iap.constants import ANDROID_SKU_PREFIX, IOS_SKU_PREFIX, MISSING_WEB_SEAT_ERROR +from ecommerce.extensions.partner.models import StockRecord + +Dispatcher = get_class('communication.utils', 'Dispatcher') +logger = logging.getLogger(__name__) + + +def create_child_products_for_mobile(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: + android_seat = create_mobile_seat(ANDROID_SKU_PREFIX, existing_web_seat) + ios_seat = create_mobile_seat(IOS_SKU_PREFIX, existing_web_seat) + return android_seat, ios_seat + + logger.error(MISSING_WEB_SEAT_ERROR, product.course.id) + return None + + +def create_mobile_seat(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() + + return mobile_stock_record