Skip to content
This repository has been archived by the owner on Nov 4, 2024. It is now read-only.

feat: create ios products on appstore for given course key #4090

Merged
merged 5 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 277 additions & 2 deletions ecommerce/extensions/iap/api/v1/utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import logging
import time


import jwt
from django.contrib.staticfiles.storage import staticfiles_storage
from oscar.core.loading import get_model
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder

Product = get_model('catalogue', 'Product')

APP_STORE_BASE_URL = "https://api.appstoreconnect.apple.com"
logger = logging.getLogger(__name__)

def products_in_basket_already_purchased(user, basket, site):
"""
Expand All @@ -17,3 +24,271 @@ def products_in_basket_already_purchased(user, basket, site):
UserAlreadyPlacedOrder.user_already_placed_order(user=user, product=product, site=site):
return True
return False

def create_ios_skus(course, ios_sku, configuration):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this to something like create_ios_products? Since it conflicts with creating skus in Ecommerce itself, where as this method is about creating products on the Appstore. Also adding a docstring would be really helpful.

headers = get_auth_headers(configuration)
try:
in_app_purchase_id = create_inapp_purchase(course, ios_sku, configuration['apple_id'], headers)
localize_inapp_purchase(in_app_purchase_id, headers)
apply_price_of_inapp_purchase(course['price'], in_app_purchase_id, headers)
upload_screenshot_of_inapp_purchase(in_app_purchase_id, headers)
submit_in_app_purchase_for_review(in_app_purchase_id, headers)
except AppStoreRequestException as store_exception:
sku_error_msg = "{} for course {} with sku {}".format(str(store_exception), course['key'], ios_sku)
logger.error(sku_error_msg)
return sku_error_msg

def request_connect_store(url, headers, data={}, method="post"):

http = Session()
retries = Retry(
total=3,
backoff_factor=0.1,
status_forcelist=[502, 503, 504, 408, 429],
allowed_methods={'POST', "GET", "PUT", "PATCH"},
)
http.mount('https://', HTTPAdapter(max_retries=retries))
if method == "post":
return http.post(url, json=data, headers=headers)
elif method == "get":
return http.get(url, headers=headers)
elif method == "patch":
return http.patch(url, json=data, headers=headers)
elif method == "put":
return http.put(url, data=data, headers=headers)


def get_auth_headers(configuration):

headers = {
"kid": configuration['key_id'],
"typ": "JWT",
"alg": "ES256"
}

payload = {
"iss": configuration['issuer_id'],
"exp": round(time.time()) + 60 * 20, # Token expiration time (20 minutes)
"aud": "appstoreconnect-v1",
"bid": configuration['ios_bundle_id']
}

private_key = configuration['private_key']
logger.error(private_key)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove this logger statement

token = jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
return headers

def create_inapp_purchase(course, ios_sku, apple_id, headers):

url = APP_STORE_BASE_URL + "/v2/inAppPurchases"
data = {
"data": {
"type": "inAppPurchases",
"attributes": {
"name": course['key'],
"productId": ios_sku,
"inAppPurchaseType": "NON_CONSUMABLE",
"reviewNote": 'This in-app purchase will unlock all the content of the course {course_name}\n\n'
'For testing the end-to-end payment flow, please follow the following steps:\n1. '
'Go to the Discover tab\n2. Search for "{course_name}"\n3. Enroll in the course'
' "{course_name}"\n4. Hit \"Upgrade to access more features\", it will open a '
'detail unlock features page\n5. Hit "Upgrade now for ${course_price}" from the'
' detail page'.format(course_name=course['name'], course_price=course['price']),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move the reviewNote string to ecommerce/extensions/iap/api/v1/constants.py?

"availableInAllTerritories": True
},
"relationships": {
"app": {
"data": {
"type": "apps",
"id": apple_id
}
}
}
}
}
response = request_connect_store(url=url, data=data, headers=headers)
logger.error(response.content)
logger.error(response.status_code)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be removed

if response.status_code == 201:
return response.json()["data"]["id"]

raise AppStoreRequestException("Couldn't create inapp purchase id")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could add the request call on a try/except, unless assuming if status_code is anything other than 201 it will always be the same exception and it won't error out before it gets to the raise

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain it a little?


def localize_inapp_purchase(in_app_purchase_id, headers):
url = APP_STORE_BASE_URL + "/v1/inAppPurchaseLocalizations"
data = {
"data": {
"type": "inAppPurchaseLocalizations",
"attributes": {
"locale": "en-US",
"name": "Upgrade Course",
"description": "Unlock course activities & certificate"
},
"relationships": {
"inAppPurchaseV2": {
"data": {
"type": "inAppPurchases",
"id": in_app_purchase_id
}
}
}
}
}
response = request_connect_store(url=url, data=data, headers=headers)
if not response.status_code == 201:
raise AppStoreRequestException("Couldn't localize purchase")


def apply_price_of_inapp_purchase(price, in_app_purchase_id, headers):
url = APP_STORE_BASE_URL + ("/v2/inAppPurchases/v2/inAppPurchases/{}/pricePoints?filter[territory]=USA"
"&include=territory&limit=8000").format(in_app_purchase_id)

response = request_connect_store(url=url, headers=headers, method='get')
if response.status_code != 200:
raise AppStoreRequestException("Couldn't fetch price points")


nearest_low_price = nearest_low_price_id = 0
for price_point in response.json()['data']:
customer_price = float(price_point['attributes']['customerPrice'])
if nearest_low_price < customer_price <= price:
nearest_low_price = customer_price
nearest_low_price_id = price_point['id']

if not nearest_low_price:
raise AppStoreRequestException("Couldn't find nearest low price point")


url = APP_STORE_BASE_URL + "/v1/inAppPurchasePriceSchedules"
data = {
"data": {
"type": "inAppPurchasePriceSchedules",
"attributes": {},
"relationships": {
"inAppPurchase": {
"data": {
"type": "inAppPurchases",
"id": in_app_purchase_id
}
},
"manualPrices": {
"data": [
{
"type": "inAppPurchasePrices",
"id": "${price}"
}
]
},
"baseTerritory": {
"data": {
"type": "territories",
"id": "USA"
}
}
}
},
"included": [
{
"id": "${price}",
"relationships": {
"inAppPurchasePricePoint": {
"data": {
"type": "inAppPurchasePricePoints",
"id": nearest_low_price_id
}
}
},
"type": "inAppPurchasePrices",
"attributes": {
"startDate": None
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this hardcoded dict to constants and only set variables here?
This would clean up a lot of code.

]
}
response = request_connect_store(url=url, data=data, headers=headers)
if response.status_code != 201:
raise AppStoreRequestException("Couldn't apply price")


def upload_screenshot_of_inapp_purchase(in_app_purchase_id, headers):
url = APP_STORE_BASE_URL + "/v1/inAppPurchaseAppStoreReviewScreenshots"
data = {
"data": {
"type": "inAppPurchaseAppStoreReviewScreenshots",
"attributes": {
"fileName": "iOS_IAP.png",
"fileSize": 124790
},
"relationships": {
"inAppPurchaseV2": {
"data": {
"id": in_app_purchase_id,
"type": "inAppPurchases"
}
}
}
}
}

response = request_connect_store(url, headers, data=data)
if not response.status_code == 201:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see sometimes you use not ... == and sometimes !=, for readability the later is easier

raise AppStoreRequestException("Couldn't get screenshot url")

response = response.json()
screenshot_id = response['data']['id']
url = response['data']['attributes']['uploadOperations'][0]['url']
with staticfiles_storage.open('images/mobile_ios_product_screenshot.png', 'rb') as image:
img_headers = headers.copy()
img_headers['Content-Type'] = 'image/png'
response = request_connect_store(url, headers=img_headers, data=image.read(), method='put')

if not response.status_code == 200:
raise AppStoreRequestException("Couldn't upload screenshot")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add breif description in a comment or a docstring about what is happening with the screenshot image here from the staticfiles? Where does the screenshot images/mobile_ios_product_screenshot.png come from in the first place?


url = APP_STORE_BASE_URL + "/v1/inAppPurchaseAppStoreReviewScreenshots/{}".format(screenshot_id)
data = {
"data": {
"type": "inAppPurchaseAppStoreReviewScreenshots",
"id": screenshot_id,
"attributes": {
"uploaded": True,
"sourceFileChecksum": ""
}
}
}

response = request_connect_store(url, headers, data=data, method='patch')

if not response.status_code == 200:
raise AppStoreRequestException("Couldn't finalize screenshot")

# If we submit right after screenshot upload we'll get error because of delay in screenshot uploading
response = request_connect_store(url, headers, method='get')
if not response.status_code == 200:
logger.info("Couldn't confirm screenshot upload but going ahead with submission")

def submit_in_app_purchase_for_review(in_app_purchase_id, headers):
url = APP_STORE_BASE_URL + "/v1/inAppPurchaseSubmissions"
data = {
"data": {
"type": "inAppPurchaseSubmissions",
"relationships": {
"inAppPurchaseV2": {
"data": {
"type": "inAppPurchases",
"id": in_app_purchase_id
}
}
}
}
}
response = request_connect_store(url=url, data=data, headers=headers)
if not response.status_code == 201:
raise AppStoreRequestException("Couldn't submit purchase")

class AppStoreRequestException(Exception):
pass
21 changes: 18 additions & 3 deletions ecommerce/extensions/iap/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
)
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.api.v1.utils import create_ios_skus, 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
Expand Down Expand Up @@ -385,6 +385,7 @@ def post(self, request):
missing_course_runs = []
failed_course_runs = []
created_skus = {}
failed_ios_skus = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

failed_ios_products in favor of failed_ios_skus?


course_run_keys = request.data.get('courses', [])
for course_run_key in course_run_keys:
Expand Down Expand Up @@ -429,13 +430,27 @@ def post(self, request):
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()
created_skus[course_run_key] = [mobile_products[0].partner_sku, mobile_products[1].partner_sku]

# create ios product on appstore
partner_short_code = request.site.siteconfiguration.partner.short_code
configuration = settings.PAYMENT_PROCESSOR_CONFIG[partner_short_code.lower()][IOSIAP.NAME.lower()]
ios_product = list((filter(lambda sku: 'ios' in sku.partner_sku, mobile_products)))[0]
course_data = {
'price': ios_product.price_excl_tax,
'name': course.name,
'key': course_run_key
}
error_msg = create_ios_skus(course_data, ios_product.partner_sku, configuration)
if error_msg:
failed_ios_skus.append(error_msg)

result = {
'new_mobile_skus': created_skus,
'failed_course_ids': failed_course_runs,
'missing_course_runs': missing_course_runs
'missing_course_runs': missing_course_runs,
'failed_ios_skus': failed_ios_skus
}
return JsonResponse(result, status=status.HTTP_200_OK)
5 changes: 5 additions & 0 deletions ecommerce/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -826,7 +826,12 @@
'google_service_account_key_file': 'SET-ME-PLEASE'
},
'ios-iap': {
"apple_id": "<put-value-here>",
'ios_bundle_id': 'org.edx.mobile',
'issuer_id': '<put-value-here>',
'key_id': '<put-value-here>',
'private_key': "<put-value-here>"

},
'stripe': {
'api_version': '2022-08-01; server_side_confirmation_beta=v1',
Expand Down
4 changes: 4 additions & 0 deletions ecommerce/settings/devstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@
'google_service_account_key_file': '<put-value-here>'
},
'ios-iap': {
"apple_id": "<put-value-here>",
'ios_bundle_id': 'org.edx.mobile',
'issuer_id': '<put-value-here>',
'key_id': '<put-value-here>',
'private_key': "<put-value-here>"
}
},
}
Expand Down
Loading