-
Notifications
You must be signed in to change notification settings - Fork 254
feat: create ios products on appstore for given course key #4090
Changes from 3 commits
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,10 +1,20 @@ | ||
import logging | ||
import time | ||
|
||
|
||
import jwt | ||
import requests | ||
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.iap.api.v1.constants import IOS_PRODUCT_REVIEW_NOTE | ||
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 +27,280 @@ | |
UserAlreadyPlacedOrder.user_already_placed_order(user=user, product=product, site=site): | ||
return True | ||
return False | ||
|
||
|
||
def create_ios_product(course, ios_sku, configuration): | ||
""" | ||
Create in app ios product on connect store. | ||
return error message in case of failure. | ||
""" | ||
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) | ||
return 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=None, method="post"): | ||
""" Request the given endpoint with multiple tries and backoff time """ | ||
http = Session() | ||
retries = Retry( | ||
total=3, | ||
backoff_factor=3, | ||
status_forcelist=[502, 503, 504, 408, 429, 409], | ||
method_whitelist={'POST', "GET", "PUT", "PATCH"}, | ||
) | ||
http.mount('https://', HTTPAdapter(max_retries=retries)) | ||
try: | ||
if method == "post": | ||
response = http.post(url, json=data, headers=headers) | ||
if method == "patch": | ||
response = http.patch(url, json=data, headers=headers) | ||
if method == "put": | ||
response = http.put(url, data=data, headers=headers) | ||
if method == "get": | ||
response = http.get(url, headers=headers) | ||
julianajlk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
except requests.RequestException as request_exc: | ||
raise AppStoreRequestException(request_exc) from request_exc | ||
|
||
return response | ||
|
||
|
||
def get_auth_headers(configuration): | ||
""" Get Bearer token with headers to call appstore """ | ||
|
||
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'] | ||
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): | ||
""" Create in app product and return its id. """ | ||
|
||
url = APP_STORE_BASE_URL + "/v2/inAppPurchases" | ||
data = { | ||
"data": { | ||
"type": "inAppPurchases", | ||
"attributes": { | ||
"name": course['key'], | ||
"productId": ios_sku, | ||
"inAppPurchaseType": "NON_CONSUMABLE", | ||
"reviewNote": IOS_PRODUCT_REVIEW_NOTE.format(course_name=course['name'], | ||
course_price=course['price']), | ||
"availableInAllTerritories": True | ||
}, | ||
"relationships": { | ||
"app": { | ||
"data": { | ||
"type": "apps", | ||
"id": apple_id | ||
} | ||
} | ||
} | ||
} | ||
} | ||
response = request_connect_store(url=url, data=data, headers=headers) | ||
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): | ||
""" Localize given in app product with US locale. """ | ||
|
||
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): | ||
""" Apply price tier to the given in app product. """ | ||
|
||
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 | ||
} | ||
} | ||
] | ||
} | ||
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): | ||
""" Upload screenshot for the given product. """ | ||
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") | ||
|
||
|
||
def submit_in_app_purchase_for_review(in_app_purchase_id, headers): | ||
""" Submit in app purchase for the final review by appstore. """ | ||
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 |
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.
total
andbackoff_factor
, would they ever have a different value? Should they be hard coded? Or can you add a comment on what this is/how this could change for the future