Skip to content

Commit

Permalink
Automate mobile skus creation (#4079)
Browse files Browse the repository at this point in the history
* feat: Automate mobile skus creation
  • Loading branch information
jawad-khan committed Dec 22, 2023
1 parent e06c983 commit d2a66ba
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 61 deletions.
4 changes: 4 additions & 0 deletions ecommerce/extensions/iap/api/v1/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]"
63 changes: 63 additions & 0 deletions ecommerce/extensions/iap/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 3 additions & 1 deletion ecommerce/extensions/iap/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
IOSRefundView,
MobileBasketAddItemsView,
MobileCheckoutView,
MobileCoursePurchaseExecutionView
MobileCoursePurchaseExecutionView,
MobileSkusCreationView
)

urlpatterns = [
Expand All @@ -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'),
]
81 changes: 77 additions & 4 deletions ecommerce/extensions/iap/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -63,19 +68,23 @@
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
from ecommerce.extensions.iap.api.v1.utils import products_in_basket_already_purchased
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
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions ecommerce/extensions/iap/constants.py
Original file line number Diff line number Diff line change
@@ -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]"
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions ecommerce/extensions/iap/utils.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit d2a66ba

Please sign in to comment.