-
Notifications
You must be signed in to change notification settings - Fork 253
feat: create ios products on appstore for given course key #4090
Changes from 1 commit
0db64c9
a67a1f2
467698f
89d5529
2bdd1a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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): | ||
""" | ||
|
@@ -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): | ||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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']), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we move the reviewNote string to |
||
"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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
] | ||
} | ||
response = request_connect_store(url=url, data=data, headers=headers) | ||
if response.status_code != 201: | ||
julianajlk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see sometimes you use |
||
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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -385,6 +385,7 @@ def post(self, request): | |
missing_course_runs = [] | ||
failed_course_runs = [] | ||
created_skus = {} | ||
failed_ios_skus = [] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
course_run_keys = request.data.get('courses', []) | ||
for course_run_key in course_run_keys: | ||
|
@@ -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) |
There was a problem hiding this comment.
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.